From 92578ba62b6c2525700e2b47ae67d6c762c469d7 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Mon, 8 Jun 2026 11:07:26 -0500
Subject: [PATCH 01/25] Read, import and display patents data
---
.../ImportPatentsToProfilesCommand.php | 156 +++++++
app/Http/Livewire/ProfileDataCard.php | 1 +
app/Profile.php | 11 +
app/ProfileData.php | 179 ++++++++
app/Services/PatentFamilyGrouper.php | 235 +++++++++++
app/Traits/ReadsPatentSpreadsheets.php | 70 ++++
composer.json | 1 +
composer.lock | 382 ++++++++++++++----
.../profile-data-cards/patents.blade.php | 11 +
resources/views/profiles/show.blade.php | 2 +
10 files changed, 965 insertions(+), 83 deletions(-)
create mode 100644 app/Console/Commands/ImportPatentsToProfilesCommand.php
create mode 100644 app/Services/PatentFamilyGrouper.php
create mode 100644 app/Traits/ReadsPatentSpreadsheets.php
create mode 100644 resources/views/livewire/profile-data-cards/patents.blade.php
diff --git a/app/Console/Commands/ImportPatentsToProfilesCommand.php b/app/Console/Commands/ImportPatentsToProfilesCommand.php
new file mode 100644
index 00000000..f5f2c1e0
--- /dev/null
+++ b/app/Console/Commands/ImportPatentsToProfilesCommand.php
@@ -0,0 +1,156 @@
+resolvePath($this->argument('input'));
+ $dry_run = (bool) $this->option('dry-run');
+
+ if (! is_file($input_path)) {
+ $this->error("Input file not found: {$input_path}");
+ return self::FAILURE;
+ }
+
+ $this->info("Reading: {$input_path}");
+ $rows = $this->readSpreadsheetRows($input_path);
+ $this->info('Rows read: ' . count($rows));
+
+ if (empty($rows)) {
+ $this->error('No rows to process.');
+ return self::FAILURE;
+ }
+
+ $curated = $grouper->groupStrict($rows);
+ $this->info('Curated groups: ' . count($curated));
+
+ $net_ids_in_csv = array_values(array_unique(array_filter(array_map(
+ fn ($r) => trim((string) ($r['NetID'] ?? '')),
+ $rows
+ ))));
+ $this->info('Unique NetIDs in CSV: ' . count($net_ids_in_csv));
+
+ $profiles = Profile::with('user')
+ ->withWhereHas('user', fn ($q) => $q->whereIn('name', $net_ids_in_csv))
+ ->get()
+ ->keyBy(fn ($p) => $p->user->name);
+
+ $this->info('Profiles matched: ' . $profiles->count());
+
+ $unmatched = array_diff($net_ids_in_csv, $profiles->keys()->all());
+ if (! empty($unmatched)) {
+ $this->warn('NetIDs in CSV with no matching profile: ' . count($unmatched));
+ foreach ($unmatched as $netId) {
+ $this->warn(" - {$netId}");
+ Log::warning("[patents:import] No profile for NetID '{$netId}'");
+ }
+ }
+
+ $curated_by_net_id = [];
+ foreach ($curated as $entry) {
+ $curated_by_net_id[$entry['net_id']][] = $entry;
+ }
+
+ $stats = ['profiles_processed' => 0, 'rows_wiped' => 0, 'rows_inserted' => 0];
+
+ if ($dry_run) {
+ $this->warn('Dry run — no changes persisted.');
+ }
+
+ foreach ($profiles as $netId => $profile) {
+ $entries = $curated_by_net_id[$netId] ?? [];
+
+ if (empty($entries)) {
+ continue;
+ }
+
+ $stats['profiles_processed']++;
+ $this->line("Profile #{$profile->id} ({$netId}): " . count($entries) . ' familie(s)');
+
+ if ($dry_run) {
+ $stats['rows_inserted'] += count($entries);
+ continue;
+ }
+
+ DB::transaction(function () use ($profile, $entries, &$stats) {
+ $wiped = ProfileData::where('profile_id', $profile->id)
+ ->where('type', 'patents')
+ ->delete();
+ $stats['rows_wiped'] += $wiped;
+
+ foreach ($entries as $entry) {
+ ProfileData::create([
+ 'profile_id' => $profile->id,
+ 'type' => 'patents',
+ 'data' => $this->buildJsonPayload($entry),
+ ]);
+ $stats['rows_inserted']++;
+ }
+ });
+ }
+
+ $this->info('---');
+ $this->info('Profiles processed: ' . $stats['profiles_processed']);
+ $this->info('Existing patents rows ' . ($dry_run ? 'would be ' : '') . 'wiped: ' . $stats['rows_wiped']);
+ $this->info('profile_data rows ' . ($dry_run ? 'would be ' : '') . 'inserted: ' . $stats['rows_inserted']);
+
+ return self::SUCCESS;
+ }
+
+ private function buildJsonPayload(array $entry): array
+ {
+ $members = [];
+ $i = 1;
+ foreach ($entry['rows'] as $r) {
+ $patent_no = trim((string) ($r['Patent No.'] ?? '')) ?: null;
+
+ $members['patent_' . $i] = [
+ 'patent_internal_id' => trim((string) ($r['Patent Internal ID'] ?? '')),
+ 'patent_title' => trim((string) ($r['Patent Title'] ?? '')),
+ 'country' => trim((string) ($r['Country'] ?? '')) ?: null,
+ 'patent_no' => $patent_no,
+ 'status' => 'granted',
+ 'filed_date' => null,
+ 'issued_date' => $this->parseDate((string) ($r['Filed Date'] ?? '')),
+ 'department' => trim((string) ($r['Department'] ?? '')) ?: null,
+ ];
+ $i++;
+ }
+
+ return [
+ 'title' => $entry['canonical_title'],
+ 'co_inventors' => $entry['co_inventors'],
+ 'family_prefix' => $entry['family_prefix'],
+ 'total_filings' => $entry['total_filings'],
+ 'countries' => $entry['countries'],
+ 'patent_numbers' => $entry['patent_numbers'],
+ 'members' => $members,
+ ];
+ }
+
+ private function resolvePath(string $path): string
+ {
+ if (str_starts_with($path, '/') || preg_match('/^[A-Z]:\\\\/', $path)) {
+ return $path;
+ }
+ return base_path($path);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Livewire/ProfileDataCard.php b/app/Http/Livewire/ProfileDataCard.php
index 0e087898..7f0067e0 100644
--- a/app/Http/Livewire/ProfileDataCard.php
+++ b/app/Http/Livewire/ProfileDataCard.php
@@ -15,6 +15,7 @@ class ProfileDataCard extends Component
'publications' => 8,
'appointments' => 10,
'awards' => 10,
+ 'patents' => 8,
'news' => 5,
'support' => 5,
'presentations' => 5,
diff --git a/app/Profile.php b/app/Profile.php
index d8f6f0e7..ed071e5c 100644
--- a/app/Profile.php
+++ b/app/Profile.php
@@ -767,6 +767,17 @@ public function awards()
{
return $this->data()->awards();
}
+
+ /**
+ * This has many patents.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+
+ public function patents()
+ {
+ return $this->data()->patents();
+ }
/**
* This has many areas.
diff --git a/app/ProfileData.php b/app/ProfileData.php
index 9685d6b7..48a5c67c 100644
--- a/app/ProfileData.php
+++ b/app/ProfileData.php
@@ -181,6 +181,174 @@ public function getImageAttribute()
return $this->getFirstMedia('images');
}
+ /**
+ * Build a citation string from the patent's title and members.
+ *
+ * Format: "Title - Country - Number (mm/dd/yyyy), Country - pending - Number (mm/dd/yyyy), ..."
+ *
+ * - Granted members: "Country - Number (issued_date)"
+ * - Pending members: "Country - pending - Number (filed_date)"
+ * - European countries sharing a patent number collapse into one "EPO - Number" entry
+ * using the earliest issued_date among them
+ */
+ public function getCitationAttribute(): ?string
+ {
+ if ($this->type !== 'patents') {
+ return null;
+ }
+
+ $data = $this->data;
+ $title = trim((string) ($data['title'] ?? ''));
+ $co_inventors = trim((string) ($data['co_inventors'] ?? ''));
+ $members = $data['members'] ?? [];
+
+ if (!is_array($members) || empty($members)) {
+ $base = $title !== '' ? $title : null;
+ if ($base !== null && $co_inventors !== '') {
+ return "{$base} - co-inventors: {$co_inventors}";
+ }
+ return $base;
+ }
+
+ $entries = [];
+ $ep_buckets = [];
+
+ $european_countries = [
+ 'European', 'Belgium', 'Switzerland', 'Germany', 'Spain', 'France',
+ 'United Kingdom', 'Ireland', 'Italy', 'The Netherlands',
+ ];
+
+ foreach ($members as $member) {
+ $country = trim((string) ($member['country'] ?? ''));
+ $status = $member['status'] ?? 'granted';
+ $patent_no = trim((string) ($member['patent_no'] ?? ''));
+ $internal_id = trim((string) ($member['patent_internal_id'] ?? ''));
+
+ $identifier = $patent_no !== '' ? $patent_no : $internal_id;
+ if ($country === '' || $identifier === '') {
+ continue;
+ }
+
+ if ($status === 'granted'
+ && $patent_no !== ''
+ && in_array($country, $european_countries, true)) {
+
+ $date_str = trim((string) ($member['issued_date'] ?? ''));
+ $date_sortable = $this->dateSortable($date_str);
+
+ if (!isset($ep_buckets[$patent_no])) {
+ $ep_buckets[$patent_no] = [
+ 'patent_no' => $patent_no,
+ 'date_display' => $this->formatCitationDate($date_str),
+ 'date_sortable' => $date_sortable,
+ ];
+ } elseif ($date_sortable < $ep_buckets[$patent_no]['date_sortable']) {
+ $ep_buckets[$patent_no]['date_display'] = $this->formatCitationDate($date_str);
+ $ep_buckets[$patent_no]['date_sortable'] = $date_sortable;
+ }
+ continue;
+ }
+
+ $entries[] = $this->formatCitationMember($member);
+ }
+
+ foreach ($ep_buckets as $bucket) {
+ $entry = "EPO - {$bucket['patent_no']}";
+ if ($bucket['date_display'] !== '') {
+ $entry .= " ({$bucket['date_display']})";
+ }
+ $entries[] = $entry;
+ }
+
+ $entries = array_filter($entries, fn ($e) => $e !== null && $e !== '');
+
+ if (empty($entries)) {
+ $base = $title !== '' ? $title : null;
+ if ($base !== null && $co_inventors !== '') {
+ return "{$base} - co-inventors: {$co_inventors}";
+ }
+ return $base;
+ }
+
+ $citation = $title !== ''
+ ? "{$title} - " . implode(', ', $entries)
+ : implode(', ', $entries);
+
+ if ($co_inventors !== '') {
+ $citation .= " - co-inventors: {$co_inventors}";
+ }
+
+ return $citation;
+ }
+
+ /**
+ * Format a single member as a citation entry.
+ */
+ private function formatCitationMember(array $member): ?string
+ {
+ $country = trim((string) ($member['country'] ?? ''));
+ $status = $member['status'] ?? 'granted';
+ $patent_no = trim((string) ($member['patent_no'] ?? ''));
+ $internal_id = trim((string) ($member['patent_internal_id'] ?? ''));
+
+ $identifier = $patent_no !== '' ? $patent_no : $internal_id;
+ if ($country === '' || $identifier === '') {
+ return null;
+ }
+
+ $country_display = match ($country) {
+ 'European' => 'EPO',
+ 'United States' => 'US',
+ 'Korea (Republic of)' => 'Korea',
+ default => $country,
+ };
+
+ $date_field = $status === 'pending' ? 'filed_date' : 'issued_date';
+ $date_str = trim((string) ($member[$date_field] ?? ''));
+ $date_display = $this->formatCitationDate($date_str);
+
+ $entry = $country_display;
+ if ($status === 'pending') {
+ $entry .= ' - pending';
+ }
+ $entry .= " - {$identifier}";
+ if ($date_display !== '') {
+ $entry .= " ({$date_display})";
+ }
+
+ return $entry;
+ }
+
+ /**
+ * Format a date string as mm/dd/yyyy. Returns empty string if unparseable.
+ */
+ private function formatCitationDate(string $date_str): string
+ {
+ if ($date_str === '') {
+ return '';
+ }
+ try {
+ return \Carbon\Carbon::parse($date_str)->format('m/d/Y');
+ } catch (\Throwable) {
+ return '';
+ }
+ }
+
+ /**
+ * Return a sortable integer (YYYYMMDD) from a date string. Returns PHP_INT_MAX if unparseable.
+ */
+ private function dateSortable(string $date_str): int
+ {
+ if ($date_str === '') {
+ return PHP_INT_MAX;
+ }
+ try {
+ return (int) \Carbon\Carbon::parse($date_str)->format('Ymd');
+ } catch (\Throwable) {
+ return PHP_INT_MAX;
+ }
+ }
+
/**
* Get the image URL. ($this->image_url)
*
@@ -250,6 +418,17 @@ public function scopeAwards($query)
return $query->where('type', 'awards');
}
+ /**
+ * Query scope for patents
+ *
+ * @param \Illuminate\Database\Query\Builder $query
+ * @return \Illuminate\Database\Query\Builder
+ */
+ public function scopePatents($query)
+ {
+ return $query->where('type', 'patents');
+ }
+
/**
* Query scope for
*
diff --git a/app/Services/PatentFamilyGrouper.php b/app/Services/PatentFamilyGrouper.php
new file mode 100644
index 00000000..ea6636cd
--- /dev/null
+++ b/app/Services/PatentFamilyGrouper.php
@@ -0,0 +1,235 @@
+familyPrefix($patent_internal_id), false];
+ }
+
+ public function normalizeTitle(string $title): string
+ {
+ $t = strtolower(trim($title));
+ $t = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $t);
+ $t = preg_replace('/\s+/', ' ', $t);
+ return trim($t);
+ }
+
+ public function groupStrict(array $rows): array
+ {
+ $co_inventor_index = $this->buildCoInventorIndex($rows);
+
+ $groups = [];
+
+ foreach ($rows as $row) {
+ $net_id = trim((string) ($row['NetID'] ?? ''));
+ $pid = (string) ($row['Patent Internal ID'] ?? '');
+ $title = (string) ($row['Patent Title'] ?? '');
+
+ if ($net_id === '' || $pid === '') {
+ continue;
+ }
+
+ $normalized_title = $this->normalizeTitle($title);
+ [$grouping_prefix, $include_country] = $this->resolveGroupingKey($pid);
+
+ $country_part = '';
+
+ if ($include_country) {
+ $country = trim((string) ($row['Country'] ?? ''));
+ if (in_array($country, self::EUROPEAN_COUNTRIES, true)) {
+ $country = 'EP';
+ }
+ $country_part = '||' . $country;
+ }
+
+ $key = $net_id . '||' . $normalized_title . '||' . $grouping_prefix . $country_part;
+
+ if (! isset($groups[$key])) {
+ $groups[$key] = [
+ 'net_id' => $net_id,
+ 'inventor' => (string) ($row['Inventor'] ?? ''),
+ 'family_prefix' => $grouping_prefix,
+ 'normalized_title' => $normalized_title,
+ 'canonical_title' => trim($title),
+ 'rows' => [],
+ ];
+ }
+
+ $clean_title = preg_replace('/\s+/', ' ', trim($title));
+ if (strlen($clean_title) > strlen($groups[$key]['canonical_title'])) {
+ $groups[$key]['canonical_title'] = $clean_title;
+ }
+
+ $groups[$key]['rows'][] = $row;
+ }
+
+ $curated = [];
+ foreach ($groups as $group) {
+ $curated[] = $this->buildEntry($group, $co_inventor_index);
+ }
+
+ usort($curated, fn ($a, $b) =>
+ [$a['inventor'], $a['canonical_title'], $a['family_prefix']]
+ <=> [$b['inventor'], $b['canonical_title'], $b['family_prefix']]
+ );
+
+ return $curated;
+ }
+
+ private function buildCoInventorIndex(array $rows): array
+ {
+ $by_patent = [];
+
+ foreach ($rows as $row) {
+ $prefix = $this->familyPrefix((string) ($row['Patent Internal ID'] ?? ''));
+ $country = trim((string) ($row['Country'] ?? ''));
+ $patent_no = trim((string) ($row['Patent No.'] ?? ''));
+ $inventor = trim((string) ($row['Inventor'] ?? ''));
+
+ if ($patent_no === '' || $inventor === '') {
+ continue;
+ }
+
+ $key = $prefix . '|' . $country . '|' . $patent_no;
+ if (! isset($by_patent[$key])) {
+ $by_patent[$key] = [];
+ }
+ if (! in_array($inventor, $by_patent[$key], true)) {
+ $by_patent[$key][] = $inventor;
+ }
+ }
+
+ return $by_patent;
+ }
+
+ private function formatInventorName(string $full_name): string
+ {
+ $parts = explode(',', $full_name, 2);
+ if (count($parts) !== 2) {
+ return trim($full_name);
+ }
+
+ $last = trim($parts[0]);
+ $first = trim($parts[1]);
+
+ if ($first === '') {
+ return $last;
+ }
+
+ $initial = mb_strtoupper(mb_substr($first, 0, 1));
+ return $initial . '. ' . $last;
+ }
+
+ private function buildEntry(array $group, array $co_inventor_index): array
+ {
+ $countries = [];
+ $patent_numbers = [];
+ $co_inventor_names = [];
+
+ foreach ($group['rows'] as $r) {
+ $c = trim((string) ($r['Country'] ?? ''));
+ if ($c !== '' && ! in_array($c, $countries, true)) {
+ $countries[] = $c;
+ }
+
+ $p = trim((string) ($r['Patent No.'] ?? ''));
+ if ($p !== '' && ! in_array($p, $patent_numbers, true)) {
+ $patent_numbers[] = $p;
+ }
+
+ $prefix = $this->familyPrefix((string) ($r['Patent Internal ID'] ?? ''));
+ $co_key = $prefix . '|' . $c . '|' . $p;
+ $this_inventor = trim((string) ($r['Inventor'] ?? ''));
+
+ foreach ($co_inventor_index[$co_key] ?? [] as $name) {
+ if ($name !== $this_inventor && ! in_array($name, $co_inventor_names, true)) {
+ $co_inventor_names[] = $name;
+ }
+ }
+ }
+
+ $co_inventors_formatted = implode(', ', array_map(
+ fn ($name) => $this->formatInventorName($name),
+ $co_inventor_names
+ ));
+
+ return [
+ 'net_id' => $group['net_id'],
+ 'inventor' => $group['inventor'],
+ 'canonical_title' => $this->toTitleCase($group['canonical_title']),
+ 'normalized_title' => $group['normalized_title'],
+ 'family_prefix' => $group['family_prefix'],
+ 'total_filings' => count($group['rows']),
+ 'countries' => $countries,
+ 'patent_numbers' => $patent_numbers,
+ 'co_inventors' => $co_inventors_formatted,
+ 'rows' => $group['rows'],
+ ];
+ }
+
+ private function toTitleCase(string $title): string
+ {
+ $words = preg_split('/(\s+)/', trim($title), -1, PREG_SPLIT_DELIM_CAPTURE);
+ $result = '';
+ $word_index = 0;
+
+ foreach ($words as $part) {
+ if (preg_match('/^\s+$/', $part)) {
+ $result .= $part;
+ continue;
+ }
+
+ $lower = mb_strtolower($part);
+
+ if ($word_index === 0 || mb_strlen($part) > 4) {
+ $result .= mb_strtoupper(mb_substr($lower, 0, 1)) . mb_substr($lower, 1);
+ } else {
+ $result .= $lower;
+ }
+
+ $word_index++;
+ }
+
+ return $result;
+ }
+}
\ No newline at end of file
diff --git a/app/Traits/ReadsPatentSpreadsheets.php b/app/Traits/ReadsPatentSpreadsheets.php
new file mode 100644
index 00000000..1ace7790
--- /dev/null
+++ b/app/Traits/ReadsPatentSpreadsheets.php
@@ -0,0 +1,70 @@
+>
+ */
+ protected function readSpreadsheetRows(string $path, ?string $sheetName = null): array
+ {
+ $spreadsheet = IOFactory::load($path);
+
+ if ($sheetName) {
+ $sheet = null;
+ foreach ($spreadsheet->getAllSheets() as $s) {
+ if (strcasecmp($s->getTitle(), $sheetName) === 0) {
+ $sheet = $s;
+ break;
+ }
+ }
+ $sheet ??= $spreadsheet->getActiveSheet();
+ } else {
+ $sheet = $spreadsheet->getActiveSheet();
+ }
+
+ $data = $sheet->toArray(null, true, true, false);
+
+ if (count($data) < 2) {
+ return [];
+ }
+
+ $headers = array_map(fn ($h) => trim((string) $h), array_shift($data));
+ $rows = [];
+
+ foreach ($data as $raw) {
+ if (count(array_filter($raw, fn ($v) => $v !== null && $v !== '')) === 0) {
+ continue;
+ }
+ $assoc = [];
+ foreach ($headers as $i => $header) {
+ $assoc[$header] = $raw[$i] ?? null;
+ }
+ $rows[] = $assoc;
+ }
+
+ return $rows;
+ }
+
+ protected function parseDate(?string $value): ?string
+ {
+ if ($value === null || trim($value) === '') {
+ return null;
+ }
+ try {
+ return \Carbon\Carbon::createFromFormat('n/j/y', trim($value))->format('Y-m-d');
+ } catch (\Throwable) {
+ try {
+ return \Carbon\Carbon::parse($value)->format('Y-m-d');
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/composer.json b/composer.json
index cbffc480..2d77ed5f 100644
--- a/composer.json
+++ b/composer.json
@@ -16,6 +16,7 @@
"league/flysystem-aws-s3-v3": "^3.0",
"livewire/livewire": "^2.10",
"owen-it/laravel-auditing": "^13.0",
+ "phpoffice/phpspreadsheet": "^5.7",
"predis/predis": "^1.1",
"sentry/sentry-laravel": "^4.15.0",
"spatie/browsershot": "^5.1.0",
diff --git a/composer.lock b/composer.lock
index a690b868..1274b929 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "8ff5298aa8683b4eae7f0de259ddc9c2",
+ "content-hash": "8410e85979806935951a534cebe4c5a8",
"packages": [
{
"name": "adldap2/adldap2",
@@ -411,6 +411,85 @@
],
"time": "2023-12-11T17:09:12+00:00"
},
+ {
+ "name": "composer/pcre",
+ "version": "3.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/pcre.git",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<1.11.10"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.12 || ^2",
+ "phpstan/phpstan-strict-rules": "^1 || ^2",
+ "phpunit/phpunit": "^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Pcre\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+ "keywords": [
+ "PCRE",
+ "preg",
+ "regex",
+ "regular expression"
+ ],
+ "support": {
+ "issues": "https://github.com/composer/pcre/issues",
+ "source": "https://github.com/composer/pcre/tree/3.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-12T16:29:46+00:00"
+ },
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -2963,6 +3042,113 @@
],
"time": "2025-07-17T11:15:13+00:00"
},
+ {
+ "name": "markbaker/complex",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/MarkBaker/PHPComplex.git",
+ "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
+ "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+ "squizlabs/php_codesniffer": "^3.7"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Complex\\": "classes/src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Baker",
+ "email": "mark@lange.demon.co.uk"
+ }
+ ],
+ "description": "PHP Class for working with complex numbers",
+ "homepage": "https://github.com/MarkBaker/PHPComplex",
+ "keywords": [
+ "complex",
+ "mathematics"
+ ],
+ "support": {
+ "issues": "https://github.com/MarkBaker/PHPComplex/issues",
+ "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
+ },
+ "time": "2022-12-06T16:21:08+00:00"
+ },
+ {
+ "name": "markbaker/matrix",
+ "version": "3.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/MarkBaker/PHPMatrix.git",
+ "reference": "728434227fe21be27ff6d86621a1b13107a2562c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
+ "reference": "728434227fe21be27ff6d86621a1b13107a2562c",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpdocumentor/phpdocumentor": "2.*",
+ "phploc/phploc": "^4.0",
+ "phpmd/phpmd": "2.*",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+ "sebastian/phpcpd": "^4.0",
+ "squizlabs/php_codesniffer": "^3.7"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Matrix\\": "classes/src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Baker",
+ "email": "mark@demon-angel.eu"
+ }
+ ],
+ "description": "PHP Class for working with matrices",
+ "homepage": "https://github.com/MarkBaker/PHPMatrix",
+ "keywords": [
+ "mathematics",
+ "matrix",
+ "vector"
+ ],
+ "support": {
+ "issues": "https://github.com/MarkBaker/PHPMatrix/issues",
+ "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
+ },
+ "time": "2022-12-02T22:17:43+00:00"
+ },
{
"name": "monolog/monolog",
"version": "3.9.0",
@@ -3700,6 +3886,115 @@
},
"time": "2025-02-21T14:58:02+00:00"
},
+ {
+ "name": "phpoffice/phpspreadsheet",
+ "version": "5.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
+ "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
+ "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
+ "shasum": ""
+ },
+ "require": {
+ "composer/pcre": "^1||^2||^3",
+ "ext-ctype": "*",
+ "ext-dom": "*",
+ "ext-fileinfo": "*",
+ "ext-filter": "*",
+ "ext-gd": "*",
+ "ext-iconv": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-simplexml": "*",
+ "ext-xml": "*",
+ "ext-xmlreader": "*",
+ "ext-xmlwriter": "*",
+ "ext-zip": "*",
+ "ext-zlib": "*",
+ "maennchen/zipstream-php": "^2.1 || ^3.0",
+ "markbaker/complex": "^3.0",
+ "markbaker/matrix": "^3.0",
+ "php": "^8.1",
+ "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
+ "dompdf/dompdf": "^2.0 || ^3.0",
+ "ext-intl": "*",
+ "friendsofphp/php-cs-fixer": "^3.2",
+ "mitoteam/jpgraph": "^10.5",
+ "mpdf/mpdf": "^8.1.1",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpstan/phpstan": "^1.1 || ^2.0",
+ "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
+ "phpunit/phpunit": "^10.5",
+ "squizlabs/php_codesniffer": "^3.7",
+ "tecnickcom/tcpdf": "^6.5"
+ },
+ "suggest": {
+ "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
+ "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
+ "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
+ "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
+ "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Maarten Balliauw",
+ "homepage": "https://blog.maartenballiauw.be"
+ },
+ {
+ "name": "Mark Baker",
+ "homepage": "https://markbakeruk.net"
+ },
+ {
+ "name": "Franck Lefevre",
+ "homepage": "https://rootslabs.net"
+ },
+ {
+ "name": "Erik Tilt"
+ },
+ {
+ "name": "Adrien Crivelli"
+ },
+ {
+ "name": "Owen Leibman"
+ }
+ ],
+ "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
+ "homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
+ "keywords": [
+ "OpenXML",
+ "excel",
+ "gnumeric",
+ "ods",
+ "php",
+ "spreadsheet",
+ "xls",
+ "xlsx"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
+ "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0"
+ },
+ "time": "2026-04-20T02:42:17+00:00"
+ },
{
"name": "phpoption/phpoption",
"version": "1.9.4",
@@ -8825,85 +9120,6 @@
],
"time": "2025-08-20T18:52:43+00:00"
},
- {
- "name": "composer/pcre",
- "version": "3.3.2",
- "source": {
- "type": "git",
- "url": "https://github.com/composer/pcre.git",
- "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
- "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
- "shasum": ""
- },
- "require": {
- "php": "^7.4 || ^8.0"
- },
- "conflict": {
- "phpstan/phpstan": "<1.11.10"
- },
- "require-dev": {
- "phpstan/phpstan": "^1.12 || ^2",
- "phpstan/phpstan-strict-rules": "^1 || ^2",
- "phpunit/phpunit": "^8 || ^9"
- },
- "type": "library",
- "extra": {
- "phpstan": {
- "includes": [
- "extension.neon"
- ]
- },
- "branch-alias": {
- "dev-main": "3.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Composer\\Pcre\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Jordi Boggiano",
- "email": "j.boggiano@seld.be",
- "homepage": "http://seld.be"
- }
- ],
- "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
- "keywords": [
- "PCRE",
- "preg",
- "regex",
- "regular expression"
- ],
- "support": {
- "issues": "https://github.com/composer/pcre/issues",
- "source": "https://github.com/composer/pcre/tree/3.3.2"
- },
- "funding": [
- {
- "url": "https://packagist.com",
- "type": "custom"
- },
- {
- "url": "https://github.com/composer",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/composer/composer",
- "type": "tidelift"
- }
- ],
- "time": "2024-11-12T16:29:46+00:00"
- },
{
"name": "composer/semver",
"version": "3.4.4",
@@ -12527,12 +12743,12 @@
],
"aliases": [],
"minimum-stability": "stable",
- "stability-flags": [],
+ "stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.3.0"
},
- "platform-dev": [],
- "plugin-api-version": "2.6.0"
+ "platform-dev": {},
+ "plugin-api-version": "2.9.0"
}
diff --git a/resources/views/livewire/profile-data-cards/patents.blade.php b/resources/views/livewire/profile-data-cards/patents.blade.php
new file mode 100644
index 00000000..aa5a7b1f
--- /dev/null
+++ b/resources/views/livewire/profile-data-cards/patents.blade.php
@@ -0,0 +1,11 @@
+
+ Patents @if($editable) Edit @endif
+ @foreach($data as $patent)
+
+ {{$patent->title}} - {{ $patent->citation }}.
+
+ @endforeach
+ @if($paginated)
+ {{ $data->links() }}
+ @endif
+
\ No newline at end of file
diff --git a/resources/views/profiles/show.blade.php b/resources/views/profiles/show.blade.php
index 173c69e3..e23a8616 100644
--- a/resources/views/profiles/show.blade.php
+++ b/resources/views/profiles/show.blade.php
@@ -125,6 +125,7 @@
@if($profile->publications()->exists())
Publications @endif
@if($profile->appointments()->exists())Appointments @endif
@if($profile->awards()->exists())Awards @endif
+ @if($profile->patents()->exists())Patents @endif
@if($profile->projects()->exists())Projects @endif
@if($profile->presentations()->exists())Presentations @endif
@if($profile->additionals()->exists())Additional Information @endif
@@ -139,6 +140,7 @@
+
From 8c2c337a818a8f24d4d6572150ad16b101465331 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Mon, 8 Jun 2026 11:08:04 -0500
Subject: [PATCH 02/25] Edit and update grouped patents records
---
app/Profile.php | 32 +++
public/js/app.js | 223 ++++++++++++++++
public/mix-manifest.json | 4 +-
resources/assets/js/app.js | 246 +++++++++++++++++-
.../edit/_patent_member_fields.blade.php | 62 +++++
.../views/profiles/edit/patents.blade.php | 91 +++++++
6 files changed, 655 insertions(+), 3 deletions(-)
create mode 100644 resources/views/profiles/edit/_patent_member_fields.blade.php
create mode 100644 resources/views/profiles/edit/patents.blade.php
diff --git a/app/Profile.php b/app/Profile.php
index ed071e5c..51c923e8 100644
--- a/app/Profile.php
+++ b/app/Profile.php
@@ -240,6 +240,7 @@ public function updateDatum($section, $request)
//iterate over each record
foreach($request->data as $entry){
+ $entry = $this->normalizeMembers($entry);
$should_save = false;
//do we have any data in the record and should we save it
foreach($entry['data'] as $key => $value){
@@ -311,6 +312,37 @@ public function updateDatum($section, $request)
Cache::tags(['profile_data'])->flush();
}
+ /**
+ * Renumber the members object so keys are sequential patent_1, patent_2, ...
+ */
+ private function normalizeMembers(array $entry, string $parent_key = 'patent'): array
+ {
+ if (!isset($entry['data']['members']) || !is_array($entry['data']['members'])) {
+ return $entry;
+ }
+
+ $renumbered = [];
+ $i = 1;
+ foreach ($entry['data']['members'] as $member) {
+ // Skip entirely empty members (all fields blank)
+ $hasContent = false;
+ foreach ($member as $value) {
+ if (!empty($value)) {
+ $hasContent = true;
+ break;
+ }
+ }
+ if (!$hasContent) {
+ continue;
+ }
+ $renumbered["{$parent_key}_{$i}"] = $member;
+ $i++;
+ }
+
+ $entry['data']['members'] = $renumbered;
+ return $entry;
+ }
+
/**
* Strips HTML tags from the specified data field.
*
diff --git a/public/js/app.js b/public/js/app.js
index 07d287a3..b18858ac 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -252,6 +252,19 @@ var profiles = function ($, undefined) {
item_container.append(new_item);
}
$(new_item).slideDown();
+ if (new_item.dataset.supportsSubrows === 'true') {
+ var subrecords_container = new_item.querySelector('.subrecords');
+ if (subrecords_container) {
+ // Remove any cloned subrecords (template stays, real ones get cleared for a fresh row)
+ subrecords_container.querySelectorAll(':scope > .subrecord:not(.template)').forEach(function (el) {
+ return el.remove();
+ });
+ subrecords_container.dataset.nextSubrowId = '0';
+ subrecords_container.style.display = 'none';
+ }
+ update_subrecord_count(new_item);
+ auto_expand_subrecords(new_item);
+ }
}
};
@@ -606,20 +619,215 @@ var profiles = function ($, undefined) {
icon.className = "fas fa-play";
}
};
+
+ /**
+ * Toggle expand/collapse of the subrecords section for a parent row.
+ *
+ * @param {Event} event
+ */
+ var toggle_subrecords = function toggle_subrecords(event) {
+ var button = event.currentTarget;
+ var row = button.closest('.record');
+ var subrecords = row === null || row === void 0 ? void 0 : row.querySelector('.subrecords');
+ if (!subrecords) return;
+ var is_expanded = button.getAttribute('aria-expanded') === 'true';
+ button.setAttribute('aria-expanded', String(!is_expanded));
+ var icon = button.querySelector('i');
+ if (icon) {
+ icon.classList.toggle('fa-chevron-right', is_expanded);
+ icon.classList.toggle('fa-chevron-down', !is_expanded);
+ }
+ if (is_expanded) {
+ $(subrecords).slideUp(200);
+ } else {
+ $(subrecords).slideDown(200);
+ }
+ };
+
+ /**
+ * Update the badge showing the number of subrecords for a parent row.
+ *
+ * @param {HTMLElement} row - the parent .record element
+ */
+ var update_subrecord_count = function update_subrecord_count(row) {
+ if (!row) return;
+ var count = row.querySelectorAll('.subrecords > .subrecord:not(.template)').length;
+ var badge = row.querySelector('.subrecord-count');
+ if (badge) {
+ badge.textContent = String(count);
+ }
+ };
+
+ /**
+ * Set the initial expanded/collapsed state of a row's subrecords section.
+ * Expanded if any subrecords exist, collapsed if empty.
+ *
+ * @param {HTMLElement} row - the parent .record element
+ */
+ var auto_expand_subrecords = function auto_expand_subrecords(row) {
+ if (!row || row.dataset.supportsSubrows !== 'true') return;
+ var subrecords = row.querySelector('.subrecords');
+ var button = row.querySelector('[data-toggle-subrecords]');
+ if (!subrecords || !button) return;
+ var count = subrecords.querySelectorAll(':scope > .subrecord:not(.template)').length;
+ var should_expand = count > 0;
+ button.setAttribute('aria-expanded', String(should_expand));
+ var icon = button.querySelector('i');
+ if (icon) {
+ icon.classList.toggle('fa-chevron-down', should_expand);
+ icon.classList.toggle('fa-chevron-right', !should_expand);
+ }
+ subrecords.style.display = should_expand ? 'block' : 'none';
+ };
+
+ /**
+ * Replace placeholder strings in a subrecord clone's name/id/for attributes.
+ *
+ * @param {HTMLElement} element - the cloned subrecord
+ * @param {string} parent_id - the parent row's data-row-id
+ * @param {string} subrow_id - the new subrow's data-subrow-id
+ */
+ var reindex_subrecord = function reindex_subrecord(element, parent_id, subrow_id) {
+ ['name', 'id', 'for'].forEach(function (attr) {
+ element.querySelectorAll("[".concat(attr, "]")).forEach(function (field) {
+ var original = field.getAttribute(attr);
+ if (original) {
+ field.setAttribute(attr, original.replace(/__parent__/g, parent_id).replace(/__index__/g, subrow_id));
+ }
+ });
+ });
+ };
+
+ /**
+ * Wire up the click handlers and pickers for a single subrow.
+ *
+ * @param {HTMLElement} subrow
+ */
+ var wire_subrow_actions = function wire_subrow_actions(subrow) {
+ subrow.querySelectorAll('.trash').forEach(function (el) {
+ $(el).off('click.subrow').on('click.subrow', remove_subrow);
+ });
+ subrow.querySelectorAll('.subrow-duplicate').forEach(function (el) {
+ $(el).off('click.subrow').on('click.subrow', duplicate_subrow);
+ });
+ subrow.querySelectorAll('.datepicker.year').forEach(function (el) {
+ $(el).datepicker(config.datepicker.year);
+ });
+ subrow.querySelectorAll('.datepicker.month').forEach(function (el) {
+ $(el).datepicker(config.datepicker.month);
+ });
+ };
+
+ /**
+ * Add a new (empty) subrecord row to a parent row's subrecords container.
+ *
+ * @param {Event} event - triggered by clicking [data-toggle="add_subrow"]
+ */
+ var add_subrow = function add_subrow(event) {
+ var button = event.currentTarget;
+ var row = button.closest('.record');
+ if (!row || row.dataset.supportsSubrows !== 'true') return;
+ var subrecords = row.querySelector('.subrecords');
+ var template = subrecords === null || subrecords === void 0 ? void 0 : subrecords.querySelector('.subrecord.template');
+ if (!subrecords || !template) return;
+ var parent_id = row.dataset.rowId;
+ var next_id = subrecords.dataset.nextSubrowId || '0';
+ subrecords.dataset.nextSubrowId = String(Number(next_id) + 1);
+ var new_subrow = template.cloneNode(true);
+ new_subrow.classList.remove('template');
+ new_subrow.dataset.subrowId = next_id;
+ new_subrow.style.display = '';
+ reindex_subrecord(new_subrow, parent_id, next_id);
+ wire_subrow_actions(new_subrow);
+ $(new_subrow).hide();
+ subrecords.appendChild(new_subrow);
+ $(new_subrow).slideDown(200);
+
+ // Make sure the section is expanded after adding
+ var toggle = row.querySelector('[data-toggle-subrecords]');
+ if (toggle && toggle.getAttribute('aria-expanded') !== 'true') {
+ toggle.click();
+ }
+ update_subrecord_count(row);
+ };
+
+ /**
+ * Duplicate an existing subrecord (copying its values) and append after it.
+ *
+ * @param {Event} event - triggered by clicking .subrow-duplicate
+ */
+ var duplicate_subrow = function duplicate_subrow(event) {
+ var button = event.currentTarget;
+ var source = button.closest('.subrecord');
+ var row = button.closest('.record');
+ var subrecords = row === null || row === void 0 ? void 0 : row.querySelector('.subrecords');
+ if (!source || !row || !subrecords) return;
+ var next_id = subrecords.dataset.nextSubrowId || '0';
+ subrecords.dataset.nextSubrowId = String(Number(next_id) + 1);
+ var new_subrow = source.cloneNode(true);
+ new_subrow.dataset.subrowId = next_id;
+
+ // Rename name/id/for attributes from source index to next_id
+ var search_for = new RegExp("\\[members\\]\\[patent_".concat(source.dataset.subrowId, "\\]"), 'g');
+ var replace_with = "[members][patent_".concat(next_id, "]");
+ ['name', 'id', 'for'].forEach(function (attr) {
+ new_subrow.querySelectorAll("[".concat(attr, "]")).forEach(function (field) {
+ var original = field.getAttribute(attr);
+ if (original) {
+ field.setAttribute(attr, original.replace(search_for, replace_with));
+ }
+ });
+ });
+
+ // Copy actual input VALUES (cloneNode doesn't copy user-entered values)
+ var source_inputs = source.querySelectorAll('input, textarea, select');
+ var new_inputs = new_subrow.querySelectorAll('input, textarea, select');
+ source_inputs.forEach(function (src, i) {
+ if (new_inputs[i]) new_inputs[i].value = src.value;
+ });
+ wire_subrow_actions(new_subrow);
+ $(new_subrow).hide();
+ source.insertAdjacentElement('afterend', new_subrow);
+ $(new_subrow).slideDown(200);
+ update_subrecord_count(row);
+ };
+
+ /**
+ * Remove a subrecord row.
+ *
+ * @param {Event} event - triggered by clicking .trash inside a subrecord
+ */
+ var remove_subrow = function remove_subrow(event) {
+ var button = event.currentTarget;
+ var subrow = button.closest('.subrecord');
+ var row = button.closest('.record');
+ if (!subrow || !row) return;
+ $(subrow).slideUp(200, function () {
+ subrow.remove();
+ update_subrecord_count(row);
+ });
+ };
return {
add_row: add_row,
+ add_subrow: add_subrow,
+ auto_expand_subrecords: auto_expand_subrecords,
clear_row: clear_row,
config: config,
deobfuscate_mail_links: deobfuscate_mail_links,
+ duplicate_subrow: duplicate_subrow,
on_list_updated: on_list_updated,
preview_selected_image: preview_selected_image,
+ remove_subrow: remove_subrow,
replace_icon: replace_icon,
registerTagEditors: registerTagEditors,
registerProfilePickers: registerProfilePickers,
toast: toast,
toggle_class: toggle_class,
toggle_show: toggle_show,
+ toggle_subrecords: toggle_subrecords,
+ update_subrecord_count: update_subrecord_count,
wait_when_submitting: wait_when_submitting,
+ wire_subrow_actions: wire_subrow_actions,
registerVideoControls: registerVideoControls
};
}(jQuery);
@@ -700,6 +908,21 @@ $(function () {
if (document.querySelectorAll('.video-cover video').length > 0 && document.querySelectorAll('.video-cover .video-control').length > 0) {
profiles.registerVideoControls();
}
+
+ // Subrecords: expand/collapse toggle (delegated so it works for new rows too)
+ $(document).on('click', '[data-toggle-subrecords]', profiles.toggle_subrecords);
+
+ // Subrecords: add new subrow (delegated)
+ $(document).on('click', '[data-toggle="add_subrow"]', profiles.add_subrow);
+
+ // Subrecords: initialize state and wire actions on existing rows
+ document.querySelectorAll('.record[data-supports-subrows="true"]').forEach(function (row) {
+ profiles.update_subrecord_count(row);
+ profiles.auto_expand_subrecords(row);
+ row.querySelectorAll('.subrecords > .subrecord:not(.template)').forEach(function (subrow) {
+ profiles.wire_subrow_actions(subrow);
+ });
+ });
});
// Trix editor settings
diff --git a/public/mix-manifest.json b/public/mix-manifest.json
index dcb76aea..8b7bef0a 100644
--- a/public/mix-manifest.json
+++ b/public/mix-manifest.json
@@ -1,6 +1,6 @@
{
- "/js/app.js": "/js/app.js?id=d53948d8ce9ce761bd7ca1001d67e004",
- "/js/app.js.map": "/js/app.js.map?id=271c8f103c569b8f5613b8778d48ee75",
+ "/js/app.js": "/js/app.js?id=dd55e5216d210347a098a5ad571fb8ff",
+ "/js/app.js.map": "/js/app.js.map?id=f5e888cf436998e40a167a6337298ea3",
"/js/manifest.js": "/js/manifest.js?id=dc9ead3d7857b522d7de22d75063453c",
"/js/manifest.js.map": "/js/manifest.js.map?id=389e00e7d7680b68d4e1d128ce27ff48",
"/css/app.css": "/css/app.css?id=0fd161f323dd5c77642c3240bbb47d16",
diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js
index 6c13e022..ed259742 100644
--- a/resources/assets/js/app.js
+++ b/resources/assets/js/app.js
@@ -201,6 +201,18 @@ var profiles = (function ($, undefined) {
item_container.append(new_item);
}
$(new_item).slideDown();
+
+ if (new_item.dataset.supportsSubrows === 'true') {
+ const subrecords_container = new_item.querySelector('.subrecords');
+ if (subrecords_container) {
+ // Remove any cloned subrecords (template stays, real ones get cleared for a fresh row)
+ subrecords_container.querySelectorAll(':scope > .subrecord:not(.template)').forEach((el) => el.remove());
+ subrecords_container.dataset.nextSubrowId = '0';
+ subrecords_container.style.display = 'none';
+ }
+ update_subrecord_count(new_item);
+ auto_expand_subrecords(new_item);
+ }
}
}
@@ -544,20 +556,236 @@ var profiles = (function ($, undefined) {
}
}
+ /**
+ * Toggle expand/collapse of the subrecords section for a parent row.
+ *
+ * @param {Event} event
+ */
+ const toggle_subrecords = function (event) {
+ const button = event.currentTarget;
+ const row = button.closest('.record');
+ const subrecords = row?.querySelector('.subrecords');
+ if (!subrecords) return;
+
+ const is_expanded = button.getAttribute('aria-expanded') === 'true';
+ button.setAttribute('aria-expanded', String(!is_expanded));
+
+ const icon = button.querySelector('i');
+ if (icon) {
+ icon.classList.toggle('fa-chevron-right', is_expanded);
+ icon.classList.toggle('fa-chevron-down', !is_expanded);
+ }
+
+ if (is_expanded) {
+ $(subrecords).slideUp(200);
+ } else {
+ $(subrecords).slideDown(200);
+ }
+ };
+
+ /**
+ * Update the badge showing the number of subrecords for a parent row.
+ *
+ * @param {HTMLElement} row - the parent .record element
+ */
+ const update_subrecord_count = function (row) {
+ if (!row) return;
+ const count = row.querySelectorAll('.subrecords > .subrecord:not(.template)').length;
+ const badge = row.querySelector('.subrecord-count');
+ if (badge) {
+ badge.textContent = String(count);
+ }
+ };
+
+ /**
+ * Set the initial expanded/collapsed state of a row's subrecords section.
+ * Expanded if any subrecords exist, collapsed if empty.
+ *
+ * @param {HTMLElement} row - the parent .record element
+ */
+ const auto_expand_subrecords = function (row) {
+ if (!row || row.dataset.supportsSubrows !== 'true') return;
+
+ const subrecords = row.querySelector('.subrecords');
+ const button = row.querySelector('[data-toggle-subrecords]');
+ if (!subrecords || !button) return;
+
+ const count = subrecords.querySelectorAll(':scope > .subrecord:not(.template)').length;
+ const should_expand = count > 0;
+
+ button.setAttribute('aria-expanded', String(should_expand));
+ const icon = button.querySelector('i');
+ if (icon) {
+ icon.classList.toggle('fa-chevron-down', should_expand);
+ icon.classList.toggle('fa-chevron-right', !should_expand);
+ }
+ subrecords.style.display = should_expand ? 'block' : 'none';
+ };
+
+ /**
+ * Replace placeholder strings in a subrecord clone's name/id/for attributes.
+ *
+ * @param {HTMLElement} element - the cloned subrecord
+ * @param {string} parent_id - the parent row's data-row-id
+ * @param {string} subrow_id - the new subrow's data-subrow-id
+ */
+ const reindex_subrecord = function (element, parent_id, subrow_id) {
+ ['name', 'id', 'for'].forEach((attr) => {
+ element.querySelectorAll(`[${attr}]`).forEach((field) => {
+ const original = field.getAttribute(attr);
+ if (original) {
+ field.setAttribute(
+ attr,
+ original.replace(/__parent__/g, parent_id).replace(/__index__/g, subrow_id)
+ );
+ }
+ });
+ });
+ };
+
+ /**
+ * Wire up the click handlers and pickers for a single subrow.
+ *
+ * @param {HTMLElement} subrow
+ */
+ const wire_subrow_actions = function (subrow) {
+ subrow.querySelectorAll('.trash').forEach((el) => {
+ $(el).off('click.subrow').on('click.subrow', remove_subrow);
+ });
+ subrow.querySelectorAll('.subrow-duplicate').forEach((el) => {
+ $(el).off('click.subrow').on('click.subrow', duplicate_subrow);
+ });
+ subrow.querySelectorAll('.datepicker.year').forEach((el) => {
+ $(el).datepicker(config.datepicker.year);
+ });
+ subrow.querySelectorAll('.datepicker.month').forEach((el) => {
+ $(el).datepicker(config.datepicker.month);
+ });
+ };
+
+ /**
+ * Add a new (empty) subrecord row to a parent row's subrecords container.
+ *
+ * @param {Event} event - triggered by clicking [data-toggle="add_subrow"]
+ */
+ const add_subrow = function (event) {
+ const button = event.currentTarget;
+ const row = button.closest('.record');
+ if (!row || row.dataset.supportsSubrows !== 'true') return;
+
+ const subrecords = row.querySelector('.subrecords');
+ const template = subrecords?.querySelector('.subrecord.template');
+ if (!subrecords || !template) return;
+
+ const parent_id = row.dataset.rowId;
+ const next_id = subrecords.dataset.nextSubrowId || '0';
+ subrecords.dataset.nextSubrowId = String(Number(next_id) + 1);
+
+ const new_subrow = template.cloneNode(true);
+ new_subrow.classList.remove('template');
+ new_subrow.dataset.subrowId = next_id;
+ new_subrow.style.display = '';
+
+ reindex_subrecord(new_subrow, parent_id, next_id);
+ wire_subrow_actions(new_subrow);
+
+ $(new_subrow).hide();
+ subrecords.appendChild(new_subrow);
+ $(new_subrow).slideDown(200);
+
+ // Make sure the section is expanded after adding
+ const toggle = row.querySelector('[data-toggle-subrecords]');
+ if (toggle && toggle.getAttribute('aria-expanded') !== 'true') {
+ toggle.click();
+ }
+
+ update_subrecord_count(row);
+ };
+
+ /**
+ * Duplicate an existing subrecord (copying its values) and append after it.
+ *
+ * @param {Event} event - triggered by clicking .subrow-duplicate
+ */
+ const duplicate_subrow = function (event) {
+ const button = event.currentTarget;
+ const source = button.closest('.subrecord');
+ const row = button.closest('.record');
+ const subrecords = row?.querySelector('.subrecords');
+ if (!source || !row || !subrecords) return;
+
+ const next_id = subrecords.dataset.nextSubrowId || '0';
+ subrecords.dataset.nextSubrowId = String(Number(next_id) + 1);
+
+ const new_subrow = source.cloneNode(true);
+ new_subrow.dataset.subrowId = next_id;
+
+ // Rename name/id/for attributes from source index to next_id
+ const search_for = new RegExp(`\\[members\\]\\[patent_${source.dataset.subrowId}\\]`, 'g');
+ const replace_with = `[members][patent_${next_id}]`;
+ ['name', 'id', 'for'].forEach((attr) => {
+ new_subrow.querySelectorAll(`[${attr}]`).forEach((field) => {
+ const original = field.getAttribute(attr);
+ if (original) {
+ field.setAttribute(attr, original.replace(search_for, replace_with));
+ }
+ });
+ });
+
+ // Copy actual input VALUES (cloneNode doesn't copy user-entered values)
+ const source_inputs = source.querySelectorAll('input, textarea, select');
+ const new_inputs = new_subrow.querySelectorAll('input, textarea, select');
+ source_inputs.forEach((src, i) => {
+ if (new_inputs[i]) new_inputs[i].value = src.value;
+ });
+
+ wire_subrow_actions(new_subrow);
+
+ $(new_subrow).hide();
+ source.insertAdjacentElement('afterend', new_subrow);
+ $(new_subrow).slideDown(200);
+
+ update_subrecord_count(row);
+ };
+
+ /**
+ * Remove a subrecord row.
+ *
+ * @param {Event} event - triggered by clicking .trash inside a subrecord
+ */
+ const remove_subrow = function (event) {
+ const button = event.currentTarget;
+ const subrow = button.closest('.subrecord');
+ const row = button.closest('.record');
+ if (!subrow || !row) return;
+
+ $(subrow).slideUp(200, () => {
+ subrow.remove();
+ update_subrecord_count(row);
+ });
+ };
+
return {
add_row: add_row,
+ add_subrow: add_subrow,
+ auto_expand_subrecords: auto_expand_subrecords,
clear_row: clear_row,
config: config,
deobfuscate_mail_links: deobfuscate_mail_links,
+ duplicate_subrow: duplicate_subrow,
on_list_updated: on_list_updated,
preview_selected_image: preview_selected_image,
+ remove_subrow: remove_subrow,
replace_icon: replace_icon,
registerTagEditors: registerTagEditors,
registerProfilePickers: registerProfilePickers,
toast: toast,
toggle_class: toggle_class,
toggle_show: toggle_show,
- wait_when_submitting : wait_when_submitting,
+ toggle_subrecords: toggle_subrecords,
+ update_subrecord_count: update_subrecord_count,
+ wait_when_submitting: wait_when_submitting,
+ wire_subrow_actions: wire_subrow_actions,
registerVideoControls: registerVideoControls,
};
@@ -643,6 +871,22 @@ $(function() {
profiles.registerVideoControls();
}
+ // Subrecords: expand/collapse toggle (delegated so it works for new rows too)
+ $(document).on('click', '[data-toggle-subrecords]', profiles.toggle_subrecords);
+
+ // Subrecords: add new subrow (delegated)
+ $(document).on('click', '[data-toggle="add_subrow"]', profiles.add_subrow);
+
+ // Subrecords: initialize state and wire actions on existing rows
+ document.querySelectorAll('.record[data-supports-subrows="true"]').forEach((row) => {
+ profiles.update_subrecord_count(row);
+ profiles.auto_expand_subrecords(row);
+
+ row.querySelectorAll('.subrecords > .subrecord:not(.template)').forEach((subrow) => {
+ profiles.wire_subrow_actions(subrow);
+ });
+ });
+
});
// Trix editor settings
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
new file mode 100644
index 00000000..0997be99
--- /dev/null
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -0,0 +1,62 @@
+
+
+ Number
+
+
+
+
+ Country
+
+
+
+
+ Status
+
+ @php $current_status = $member['status'] ?? 'granted'; @endphp
+ Granted
+ Pending
+ Expired
+
+
+
+
+ Filed Date
+
+
+
+
+ Issued Date
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Title (optional)
+
+
+
\ No newline at end of file
diff --git a/resources/views/profiles/edit/patents.blade.php b/resources/views/profiles/edit/patents.blade.php
new file mode 100644
index 00000000..42aa2d66
--- /dev/null
+++ b/resources/views/profiles/edit/patents.blade.php
@@ -0,0 +1,91 @@
+@extends('profiles.edit.layout')
+
+@section('section_name', 'Patents')
+
+@php
+ /**
+ * Helper: compute the next available subrow ID for a patent.
+ * Looks at the existing members object keys, extracts the integer part of "patent_N",
+ * and returns max + 1. Returns 1 if no members exist.
+ */
+ $next_subrow_id = function ($members) {
+ if (empty($members)) {
+ return 1;
+ }
+ $max_n = 0;
+ foreach (array_keys($members) as $key) {
+ if (preg_match('/^patent_(\d+)$/', $key, $m)) {
+ $max_n = max($max_n, (int) $m[1]);
+ }
+ }
+ return $max_n + 1;
+ };
+@endphp
+
+@section('form')
+ @foreach ($data as $patent)
+
+ @endforeach
+@endsection
\ No newline at end of file
From 7977946425aca861f7f6610750a7427b28eefdc9 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Sun, 14 Jun 2026 16:26:42 -0500
Subject: [PATCH 03/25] Refactor patents import to read CSV native
---
app/Traits/ReadsPatentSpreadsheets.php | 84 +++---
composer.json | 1 -
composer.lock | 376 ++++++-------------------
3 files changed, 125 insertions(+), 336 deletions(-)
diff --git a/app/Traits/ReadsPatentSpreadsheets.php b/app/Traits/ReadsPatentSpreadsheets.php
index 1ace7790..98190a07 100644
--- a/app/Traits/ReadsPatentSpreadsheets.php
+++ b/app/Traits/ReadsPatentSpreadsheets.php
@@ -2,69 +2,75 @@
namespace App\Traits;
-use PhpOffice\PhpSpreadsheet\IOFactory;
+use Carbon\Carbon;
+use RuntimeException;
trait ReadsPatentSpreadsheets
{
/**
- * Read a CSV or XLSX file and return rows keyed by header name.
- * Optionally targets a specific sheet by name; defaults to the active sheet.
+ * Read a CSV file and return rows keyed by header name.
*
- * @return array>
+ * Expects: UTF-8 encoded CSV with a header row.
+ * Excel "CSV UTF-8" export works; "CSV (Comma delimited)" may need manual
+ * encoding conversion if your data has non-ASCII characters.
*/
- protected function readSpreadsheetRows(string $path, ?string $sheetName = null): array
+ protected function readSpreadsheetRows(string $path): array
{
- $spreadsheet = IOFactory::load($path);
-
- if ($sheetName) {
- $sheet = null;
- foreach ($spreadsheet->getAllSheets() as $s) {
- if (strcasecmp($s->getTitle(), $sheetName) === 0) {
- $sheet = $s;
- break;
- }
- }
- $sheet ??= $spreadsheet->getActiveSheet();
- } else {
- $sheet = $spreadsheet->getActiveSheet();
+ if (! is_file($path)) {
+ throw new RuntimeException("File not found: {$path}");
}
- $data = $sheet->toArray(null, true, true, false);
+ $handle = fopen($path, 'r');
+ if ($handle === false) {
+ throw new RuntimeException("Could not open file: {$path}");
+ }
- if (count($data) < 2) {
- return [];
+ $headers = fgetcsv($handle);
+ if ($headers === false) {
+ fclose($handle);
+ throw new RuntimeException("Empty file or invalid CSV: {$path}");
}
- $headers = array_map(fn ($h) => trim((string) $h), array_shift($data));
- $rows = [];
+ // Strip UTF-8 BOM from first header (Excel "CSV UTF-8" adds one)
+ $headers[0] = preg_replace('/^\xEF\xBB\xBF/', '', $headers[0]);
+ $headers = array_map('trim', $headers);
- foreach ($data as $raw) {
- if (count(array_filter($raw, fn ($v) => $v !== null && $v !== '')) === 0) {
+ $rows = [];
+ while (($row = fgetcsv($handle)) !== false) {
+ // Skip blank rows (Excel sometimes adds trailing empty rows)
+ $non_empty = array_filter($row, fn ($v) => $v !== null && trim((string) $v) !== '');
+ if (empty($non_empty)) {
continue;
}
- $assoc = [];
- foreach ($headers as $i => $header) {
- $assoc[$header] = $raw[$i] ?? null;
- }
- $rows[] = $assoc;
+
+ // Pad/truncate row to match header count
+ $row = array_pad(array_slice($row, 0, count($headers)), count($headers), '');
+
+ // Trim each value
+ $row = array_map(fn ($v) => is_string($v) ? trim($v) : $v, $row);
+
+ $rows[] = array_combine($headers, $row);
}
+ fclose($handle);
return $rows;
}
- protected function parseDate(?string $value): ?string
+ /**
+ * Parse a date string into ISO format (YYYY-MM-DD).
+ * Returns null for unparseable or empty input.
+ */
+ protected function parseDate(string $value): ?string
{
- if ($value === null || trim($value) === '') {
+ $value = trim($value);
+ if ($value === '') {
return null;
}
+
try {
- return \Carbon\Carbon::createFromFormat('n/j/y', trim($value))->format('Y-m-d');
- } catch (\Throwable) {
- try {
- return \Carbon\Carbon::parse($value)->format('Y-m-d');
- } catch (\Throwable) {
- return null;
- }
+ return Carbon::parse($value)->format('Y-m-d');
+ } catch (\Exception $e) {
+ return null;
}
}
}
\ No newline at end of file
diff --git a/composer.json b/composer.json
index 2d77ed5f..cbffc480 100644
--- a/composer.json
+++ b/composer.json
@@ -16,7 +16,6 @@
"league/flysystem-aws-s3-v3": "^3.0",
"livewire/livewire": "^2.10",
"owen-it/laravel-auditing": "^13.0",
- "phpoffice/phpspreadsheet": "^5.7",
"predis/predis": "^1.1",
"sentry/sentry-laravel": "^4.15.0",
"spatie/browsershot": "^5.1.0",
diff --git a/composer.lock b/composer.lock
index 1274b929..6762bf83 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "8410e85979806935951a534cebe4c5a8",
+ "content-hash": "8ff5298aa8683b4eae7f0de259ddc9c2",
"packages": [
{
"name": "adldap2/adldap2",
@@ -411,85 +411,6 @@
],
"time": "2023-12-11T17:09:12+00:00"
},
- {
- "name": "composer/pcre",
- "version": "3.3.2",
- "source": {
- "type": "git",
- "url": "https://github.com/composer/pcre.git",
- "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
- "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
- "shasum": ""
- },
- "require": {
- "php": "^7.4 || ^8.0"
- },
- "conflict": {
- "phpstan/phpstan": "<1.11.10"
- },
- "require-dev": {
- "phpstan/phpstan": "^1.12 || ^2",
- "phpstan/phpstan-strict-rules": "^1 || ^2",
- "phpunit/phpunit": "^8 || ^9"
- },
- "type": "library",
- "extra": {
- "phpstan": {
- "includes": [
- "extension.neon"
- ]
- },
- "branch-alias": {
- "dev-main": "3.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Composer\\Pcre\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Jordi Boggiano",
- "email": "j.boggiano@seld.be",
- "homepage": "http://seld.be"
- }
- ],
- "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
- "keywords": [
- "PCRE",
- "preg",
- "regex",
- "regular expression"
- ],
- "support": {
- "issues": "https://github.com/composer/pcre/issues",
- "source": "https://github.com/composer/pcre/tree/3.3.2"
- },
- "funding": [
- {
- "url": "https://packagist.com",
- "type": "custom"
- },
- {
- "url": "https://github.com/composer",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/composer/composer",
- "type": "tidelift"
- }
- ],
- "time": "2024-11-12T16:29:46+00:00"
- },
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -3042,113 +2963,6 @@
],
"time": "2025-07-17T11:15:13+00:00"
},
- {
- "name": "markbaker/complex",
- "version": "3.0.2",
- "source": {
- "type": "git",
- "url": "https://github.com/MarkBaker/PHPComplex.git",
- "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
- "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
- "shasum": ""
- },
- "require": {
- "php": "^7.2 || ^8.0"
- },
- "require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
- "phpcompatibility/php-compatibility": "^9.3",
- "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
- "squizlabs/php_codesniffer": "^3.7"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Complex\\": "classes/src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Mark Baker",
- "email": "mark@lange.demon.co.uk"
- }
- ],
- "description": "PHP Class for working with complex numbers",
- "homepage": "https://github.com/MarkBaker/PHPComplex",
- "keywords": [
- "complex",
- "mathematics"
- ],
- "support": {
- "issues": "https://github.com/MarkBaker/PHPComplex/issues",
- "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
- },
- "time": "2022-12-06T16:21:08+00:00"
- },
- {
- "name": "markbaker/matrix",
- "version": "3.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/MarkBaker/PHPMatrix.git",
- "reference": "728434227fe21be27ff6d86621a1b13107a2562c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
- "reference": "728434227fe21be27ff6d86621a1b13107a2562c",
- "shasum": ""
- },
- "require": {
- "php": "^7.1 || ^8.0"
- },
- "require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
- "phpcompatibility/php-compatibility": "^9.3",
- "phpdocumentor/phpdocumentor": "2.*",
- "phploc/phploc": "^4.0",
- "phpmd/phpmd": "2.*",
- "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
- "sebastian/phpcpd": "^4.0",
- "squizlabs/php_codesniffer": "^3.7"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Matrix\\": "classes/src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Mark Baker",
- "email": "mark@demon-angel.eu"
- }
- ],
- "description": "PHP Class for working with matrices",
- "homepage": "https://github.com/MarkBaker/PHPMatrix",
- "keywords": [
- "mathematics",
- "matrix",
- "vector"
- ],
- "support": {
- "issues": "https://github.com/MarkBaker/PHPMatrix/issues",
- "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
- },
- "time": "2022-12-02T22:17:43+00:00"
- },
{
"name": "monolog/monolog",
"version": "3.9.0",
@@ -3886,115 +3700,6 @@
},
"time": "2025-02-21T14:58:02+00:00"
},
- {
- "name": "phpoffice/phpspreadsheet",
- "version": "5.7.0",
- "source": {
- "type": "git",
- "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
- "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
- "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
- "shasum": ""
- },
- "require": {
- "composer/pcre": "^1||^2||^3",
- "ext-ctype": "*",
- "ext-dom": "*",
- "ext-fileinfo": "*",
- "ext-filter": "*",
- "ext-gd": "*",
- "ext-iconv": "*",
- "ext-libxml": "*",
- "ext-mbstring": "*",
- "ext-simplexml": "*",
- "ext-xml": "*",
- "ext-xmlreader": "*",
- "ext-xmlwriter": "*",
- "ext-zip": "*",
- "ext-zlib": "*",
- "maennchen/zipstream-php": "^2.1 || ^3.0",
- "markbaker/complex": "^3.0",
- "markbaker/matrix": "^3.0",
- "php": "^8.1",
- "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
- },
- "require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
- "dompdf/dompdf": "^2.0 || ^3.0",
- "ext-intl": "*",
- "friendsofphp/php-cs-fixer": "^3.2",
- "mitoteam/jpgraph": "^10.5",
- "mpdf/mpdf": "^8.1.1",
- "phpcompatibility/php-compatibility": "^9.3",
- "phpstan/phpstan": "^1.1 || ^2.0",
- "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
- "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
- "phpunit/phpunit": "^10.5",
- "squizlabs/php_codesniffer": "^3.7",
- "tecnickcom/tcpdf": "^6.5"
- },
- "suggest": {
- "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
- "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
- "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
- "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
- "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Maarten Balliauw",
- "homepage": "https://blog.maartenballiauw.be"
- },
- {
- "name": "Mark Baker",
- "homepage": "https://markbakeruk.net"
- },
- {
- "name": "Franck Lefevre",
- "homepage": "https://rootslabs.net"
- },
- {
- "name": "Erik Tilt"
- },
- {
- "name": "Adrien Crivelli"
- },
- {
- "name": "Owen Leibman"
- }
- ],
- "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
- "homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
- "keywords": [
- "OpenXML",
- "excel",
- "gnumeric",
- "ods",
- "php",
- "spreadsheet",
- "xls",
- "xlsx"
- ],
- "support": {
- "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
- "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0"
- },
- "time": "2026-04-20T02:42:17+00:00"
- },
{
"name": "phpoption/phpoption",
"version": "1.9.4",
@@ -9120,6 +8825,85 @@
],
"time": "2025-08-20T18:52:43+00:00"
},
+ {
+ "name": "composer/pcre",
+ "version": "3.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/pcre.git",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<1.11.10"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.12 || ^2",
+ "phpstan/phpstan-strict-rules": "^1 || ^2",
+ "phpunit/phpunit": "^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Pcre\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+ "keywords": [
+ "PCRE",
+ "preg",
+ "regex",
+ "regular expression"
+ ],
+ "support": {
+ "issues": "https://github.com/composer/pcre/issues",
+ "source": "https://github.com/composer/pcre/tree/3.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-12T16:29:46+00:00"
+ },
{
"name": "composer/semver",
"version": "3.4.4",
From e6b34997afba755a022f781b0a33019fe71ee98d Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Sun, 14 Jun 2026 19:05:13 -0500
Subject: [PATCH 04/25] Fix bug undefined ID when adding a new subrow
---
public/js/app.js | 8 ++++----
public/mix-manifest.json | 4 ++--
resources/assets/js/app.js | 7 +++----
resources/views/profiles/edit/patents.blade.php | 16 +++++++++-------
4 files changed, 18 insertions(+), 17 deletions(-)
diff --git a/public/js/app.js b/public/js/app.js
index b18858ac..77d86f20 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -728,15 +728,15 @@ var profiles = function ($, undefined) {
var row = button.closest('.record');
if (!row || row.dataset.supportsSubrows !== 'true') return;
var subrecords = row.querySelector('.subrecords');
- var template = subrecords === null || subrecords === void 0 ? void 0 : subrecords.querySelector('.subrecord.template');
+ var template = row.querySelector('template');
if (!subrecords || !template) return;
var parent_id = row.dataset.rowId;
var next_id = subrecords.dataset.nextSubrowId || '0';
subrecords.dataset.nextSubrowId = String(Number(next_id) + 1);
- var new_subrow = template.cloneNode(true);
- new_subrow.classList.remove('template');
+
+ // .content is a DocumentFragment — clone its first element
+ var new_subrow = template.content.firstElementChild.cloneNode(true);
new_subrow.dataset.subrowId = next_id;
- new_subrow.style.display = '';
reindex_subrecord(new_subrow, parent_id, next_id);
wire_subrow_actions(new_subrow);
$(new_subrow).hide();
diff --git a/public/mix-manifest.json b/public/mix-manifest.json
index 8b7bef0a..595a2be2 100644
--- a/public/mix-manifest.json
+++ b/public/mix-manifest.json
@@ -1,6 +1,6 @@
{
- "/js/app.js": "/js/app.js?id=dd55e5216d210347a098a5ad571fb8ff",
- "/js/app.js.map": "/js/app.js.map?id=f5e888cf436998e40a167a6337298ea3",
+ "/js/app.js": "/js/app.js?id=dcbb1b5b4604470fc46c6bcb17fd0e1b",
+ "/js/app.js.map": "/js/app.js.map?id=b70c8cb2237f6e5d118c24f8401c1f6a",
"/js/manifest.js": "/js/manifest.js?id=dc9ead3d7857b522d7de22d75063453c",
"/js/manifest.js.map": "/js/manifest.js.map?id=389e00e7d7680b68d4e1d128ce27ff48",
"/css/app.css": "/css/app.css?id=0fd161f323dd5c77642c3240bbb47d16",
diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js
index ed259742..0dd6a987 100644
--- a/resources/assets/js/app.js
+++ b/resources/assets/js/app.js
@@ -674,17 +674,16 @@ var profiles = (function ($, undefined) {
if (!row || row.dataset.supportsSubrows !== 'true') return;
const subrecords = row.querySelector('.subrecords');
- const template = subrecords?.querySelector('.subrecord.template');
+ const template = row.querySelector('template');
if (!subrecords || !template) return;
const parent_id = row.dataset.rowId;
const next_id = subrecords.dataset.nextSubrowId || '0';
subrecords.dataset.nextSubrowId = String(Number(next_id) + 1);
- const new_subrow = template.cloneNode(true);
- new_subrow.classList.remove('template');
+ // .content is a DocumentFragment — clone its first element
+ const new_subrow = template.content.firstElementChild.cloneNode(true);
new_subrow.dataset.subrowId = next_id;
- new_subrow.style.display = '';
reindex_subrecord(new_subrow, parent_id, next_id);
wire_subrow_actions(new_subrow);
diff --git a/resources/views/profiles/edit/patents.blade.php b/resources/views/profiles/edit/patents.blade.php
index 42aa2d66..5b485cd4 100644
--- a/resources/views/profiles/edit/patents.blade.php
+++ b/resources/views/profiles/edit/patents.blade.php
@@ -61,13 +61,15 @@ class="btn btn-sm btn-outline-primary"
style="display:none;">
{{-- Hidden template to add_subrow() to clone new members --}}
-
- @include('profiles.edit._patent_member_fields', [
- 'parent_id' => '__parent__',
- 'index' => '__index__',
- 'member' => null,
- ])
-
+
+
+ @include('profiles.edit._patent_member_fields', [
+ 'parent_id' => '__parent__',
+ 'index' => '__index__',
+ 'member' => null,
+ ])
+
+
{{-- Existing members — keys like patent_1, patent_2, ... preserved --}}
@foreach ($patent->members ?? [] as $member_key => $member)
From 775e1f48452df213928260a5f0f286734a906553 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Sun, 14 Jun 2026 19:06:17 -0500
Subject: [PATCH 05/25] Add patent jurisdictions to settings
---
app/Http/Controllers/ProfilesController.php | 7 ++++++-
app/ProfileData.php | 8 ++++++++
resources/views/settings.blade.php | 6 ++++++
3 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/app/Http/Controllers/ProfilesController.php b/app/Http/Controllers/ProfilesController.php
index 64fdd2ba..06cac8ee 100644
--- a/app/Http/Controllers/ProfilesController.php
+++ b/app/Http/Controllers/ProfilesController.php
@@ -14,6 +14,7 @@
use App\Http\Requests\ProfileSearchRequest;
use App\Http\Requests\ProfileUpdateRequest;
use App\School;
+use App\Setting;
use Illuminate\Contracts\View\View as ViewContract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -253,7 +254,11 @@ public function edit(Profile $profile, string $section): View|ViewContract|Redir
$data->push($record);
}
- return view('profiles.edit', compact('profile', 'section', 'data'));
+ if ($section === 'patents') {
+ $jurisdictions = ProfileData::jurisdictions();
+ }
+
+ return view('profiles.edit', compact('profile', 'section', 'data', 'jurisdictions'));
}
/**
diff --git a/app/ProfileData.php b/app/ProfileData.php
index 48a5c67c..253aa91a 100644
--- a/app/ProfileData.php
+++ b/app/ProfileData.php
@@ -334,6 +334,14 @@ private function formatCitationDate(string $date_str): string
}
}
+ public static function jurisdictions()
+ {
+ $patent_jurisdictions_setting = optional(Setting::whereName('patent_jurisdictions')->first())->value;
+ $jurisdictions = $patent_jurisdictions_setting ? preg_split("/[\r\n]+/", $patent_jurisdictions_setting) : [];
+
+ return collect($jurisdictions)->combine($jurisdictions);
+ }
+
/**
* Return a sortable integer (YYYYMMDD) from a date string. Returns PHP_INT_MAX if unparseable.
*/
diff --git a/resources/views/settings.blade.php b/resources/views/settings.blade.php
index cd5629be..aba85329 100644
--- a/resources/views/settings.blade.php
+++ b/resources/views/settings.blade.php
@@ -224,6 +224,12 @@ class="btn btn-link"
@endif
+
+
+ Patent Jurisdictions
+ One per line. Used on patents section edit view.
+
+
From e71960d98ec82167ba4456f6b8445a6496f24dcc Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Sun, 14 Jun 2026 19:08:06 -0500
Subject: [PATCH 06/25] Rename country field to jurisdiction
---
.../ImportPatentsToProfilesCommand.php | 2 +-
app/ProfileData.php | 23 ++++++++++---------
.../edit/_patent_member_fields.blade.php | 20 +++++++++++-----
3 files changed, 27 insertions(+), 18 deletions(-)
diff --git a/app/Console/Commands/ImportPatentsToProfilesCommand.php b/app/Console/Commands/ImportPatentsToProfilesCommand.php
index f5f2c1e0..0357aabe 100644
--- a/app/Console/Commands/ImportPatentsToProfilesCommand.php
+++ b/app/Console/Commands/ImportPatentsToProfilesCommand.php
@@ -125,7 +125,7 @@ private function buildJsonPayload(array $entry): array
$members['patent_' . $i] = [
'patent_internal_id' => trim((string) ($r['Patent Internal ID'] ?? '')),
'patent_title' => trim((string) ($r['Patent Title'] ?? '')),
- 'country' => trim((string) ($r['Country'] ?? '')) ?: null,
+ 'jurisdiction' => trim((string) ($r['Country'] ?? '')) ?: null,
'patent_no' => $patent_no,
'status' => 'granted',
'filed_date' => null,
diff --git a/app/ProfileData.php b/app/ProfileData.php
index 253aa91a..6d087917 100644
--- a/app/ProfileData.php
+++ b/app/ProfileData.php
@@ -184,10 +184,10 @@ public function getImageAttribute()
/**
* Build a citation string from the patent's title and members.
*
- * Format: "Title - Country - Number (mm/dd/yyyy), Country - pending - Number (mm/dd/yyyy), ..."
+ * Format: "Title - jurisdiction - Number (mm/dd/yyyy), jurisdiction - pending - Number (mm/dd/yyyy), ..."
*
- * - Granted members: "Country - Number (issued_date)"
- * - Pending members: "Country - pending - Number (filed_date)"
+ * - Granted members: "jurisdiction - Number (published_date)"
+ * - Pending members: "jurisdiction - pending - Number (filed_date)"
* - European countries sharing a patent number collapse into one "EPO - Number" entry
* using the earliest issued_date among them
*/
@@ -219,19 +219,19 @@ public function getCitationAttribute(): ?string
];
foreach ($members as $member) {
- $country = trim((string) ($member['country'] ?? ''));
+ $jurisdiction = trim((string) ($member['jurisdiction'] ?? ''));
$status = $member['status'] ?? 'granted';
$patent_no = trim((string) ($member['patent_no'] ?? ''));
$internal_id = trim((string) ($member['patent_internal_id'] ?? ''));
$identifier = $patent_no !== '' ? $patent_no : $internal_id;
- if ($country === '' || $identifier === '') {
+ if ($jurisdiction === '' || $identifier === '') {
continue;
}
if ($status === 'granted'
&& $patent_no !== ''
- && in_array($country, $european_countries, true)) {
+ && in_array($jurisdiction, $european_countries, true)) {
$date_str = trim((string) ($member['issued_date'] ?? ''));
$date_sortable = $this->dateSortable($date_str);
@@ -286,28 +286,29 @@ public function getCitationAttribute(): ?string
*/
private function formatCitationMember(array $member): ?string
{
- $country = trim((string) ($member['country'] ?? ''));
+ $jurisdiction = trim((string) ($member['jurisdiction'] ?? ''));
$status = $member['status'] ?? 'granted';
$patent_no = trim((string) ($member['patent_no'] ?? ''));
$internal_id = trim((string) ($member['patent_internal_id'] ?? ''));
$identifier = $patent_no !== '' ? $patent_no : $internal_id;
- if ($country === '' || $identifier === '') {
+ if ($jurisdiction === '' || $identifier === '') {
return null;
}
- $country_display = match ($country) {
+ $jurisdiction_display = match ($jurisdiction) {
'European' => 'EPO',
'United States' => 'US',
'Korea (Republic of)' => 'Korea',
- default => $country,
+ default => $jurisdiction,
};
$date_field = $status === 'pending' ? 'filed_date' : 'issued_date';
$date_str = trim((string) ($member[$date_field] ?? ''));
$date_display = $this->formatCitationDate($date_str);
- $entry = $country_display;
+ $entry = $jurisdiction_display;
+
if ($status === 'pending') {
$entry .= ' - pending';
}
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index 0997be99..b93b17f7 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -7,12 +7,20 @@
value="{{ $member['patent_no'] ?? '' }}">
-
-
Country
-
+
+
+ Jurisdiction
+
+ Select a value
+ @foreach($jurisdictions as $jurisdiction)
+
+ {{ $jurisdiction }}
+
+ @endforeach
+
From 84aafb61c193f0a3de2fe3da83f365d7930ef38d Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Sun, 14 Jun 2026 19:11:13 -0500
Subject: [PATCH 07/25] Update patent statuses terminology
---
.../Commands/ImportPatentsToProfilesCommand.php | 5 ++---
app/ProfileData.php | 6 +++---
.../profiles/edit/_patent_member_fields.blade.php | 12 ++++++------
3 files changed, 11 insertions(+), 12 deletions(-)
diff --git a/app/Console/Commands/ImportPatentsToProfilesCommand.php b/app/Console/Commands/ImportPatentsToProfilesCommand.php
index 0357aabe..e5558fe9 100644
--- a/app/Console/Commands/ImportPatentsToProfilesCommand.php
+++ b/app/Console/Commands/ImportPatentsToProfilesCommand.php
@@ -127,10 +127,9 @@ private function buildJsonPayload(array $entry): array
'patent_title' => trim((string) ($r['Patent Title'] ?? '')),
'jurisdiction' => trim((string) ($r['Country'] ?? '')) ?: null,
'patent_no' => $patent_no,
- 'status' => 'granted',
+ 'status' => 'published',
'filed_date' => null,
- 'issued_date' => $this->parseDate((string) ($r['Filed Date'] ?? '')),
- 'department' => trim((string) ($r['Department'] ?? '')) ?: null,
+ 'published_date' => $this->parseDate((string) ($r['Filed Date'] ?? '')),
];
$i++;
}
diff --git a/app/ProfileData.php b/app/ProfileData.php
index 6d087917..16ff9065 100644
--- a/app/ProfileData.php
+++ b/app/ProfileData.php
@@ -189,7 +189,7 @@ public function getImageAttribute()
* - Granted members: "jurisdiction - Number (published_date)"
* - Pending members: "jurisdiction - pending - Number (filed_date)"
* - European countries sharing a patent number collapse into one "EPO - Number" entry
- * using the earliest issued_date among them
+ * using the earliest published_date among them
*/
public function getCitationAttribute(): ?string
{
@@ -233,7 +233,7 @@ public function getCitationAttribute(): ?string
&& $patent_no !== ''
&& in_array($jurisdiction, $european_countries, true)) {
- $date_str = trim((string) ($member['issued_date'] ?? ''));
+ $date_str = trim((string) ($member['published_date'] ?? ''));
$date_sortable = $this->dateSortable($date_str);
if (!isset($ep_buckets[$patent_no])) {
@@ -303,7 +303,7 @@ private function formatCitationMember(array $member): ?string
default => $jurisdiction,
};
- $date_field = $status === 'pending' ? 'filed_date' : 'issued_date';
+ $date_field = $status === 'pending' ? 'filed_date' : 'published_date';
$date_str = trim((string) ($member[$date_field] ?? ''));
$date_display = $this->formatCitationDate($date_str);
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index b93b17f7..43a28e5d 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -28,8 +28,8 @@
- @php $current_status = $member['status'] ?? 'granted'; @endphp
- Granted
+ @php $current_status = $member['status'] ?? 'published'; @endphp
+ Published
Pending
Expired
@@ -44,11 +44,11 @@
- Issued Date
+ Published Date
+ id="data[{{ $parent_id }}][data][members][patent_{{ $index }}][published_date]"
+ name="data[{{ $parent_id }}][data][members][patent_{{ $index }}][published_date]"
+ value="{{ $member['published_date'] ?? '' }}">
From fab9a4bf4083fa978a5c84d10ead4196b21c8d5c Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Sun, 14 Jun 2026 19:44:29 -0500
Subject: [PATCH 08/25] Remove unnecessary import data
---
.../Commands/ImportPatentsToProfilesCommand.php | 3 ---
app/Services/PatentFamilyGrouper.php | 12 ------------
2 files changed, 15 deletions(-)
diff --git a/app/Console/Commands/ImportPatentsToProfilesCommand.php b/app/Console/Commands/ImportPatentsToProfilesCommand.php
index e5558fe9..af884e5d 100644
--- a/app/Console/Commands/ImportPatentsToProfilesCommand.php
+++ b/app/Console/Commands/ImportPatentsToProfilesCommand.php
@@ -138,9 +138,6 @@ private function buildJsonPayload(array $entry): array
'title' => $entry['canonical_title'],
'co_inventors' => $entry['co_inventors'],
'family_prefix' => $entry['family_prefix'],
- 'total_filings' => $entry['total_filings'],
- 'countries' => $entry['countries'],
- 'patent_numbers' => $entry['patent_numbers'],
'members' => $members,
];
}
diff --git a/app/Services/PatentFamilyGrouper.php b/app/Services/PatentFamilyGrouper.php
index ea6636cd..1c370192 100644
--- a/app/Services/PatentFamilyGrouper.php
+++ b/app/Services/PatentFamilyGrouper.php
@@ -162,20 +162,11 @@ private function formatInventorName(string $full_name): string
private function buildEntry(array $group, array $co_inventor_index): array
{
- $countries = [];
- $patent_numbers = [];
$co_inventor_names = [];
foreach ($group['rows'] as $r) {
$c = trim((string) ($r['Country'] ?? ''));
- if ($c !== '' && ! in_array($c, $countries, true)) {
- $countries[] = $c;
- }
-
$p = trim((string) ($r['Patent No.'] ?? ''));
- if ($p !== '' && ! in_array($p, $patent_numbers, true)) {
- $patent_numbers[] = $p;
- }
$prefix = $this->familyPrefix((string) ($r['Patent Internal ID'] ?? ''));
$co_key = $prefix . '|' . $c . '|' . $p;
@@ -199,9 +190,6 @@ private function buildEntry(array $group, array $co_inventor_index): array
'canonical_title' => $this->toTitleCase($group['canonical_title']),
'normalized_title' => $group['normalized_title'],
'family_prefix' => $group['family_prefix'],
- 'total_filings' => count($group['rows']),
- 'countries' => $countries,
- 'patent_numbers' => $patent_numbers,
'co_inventors' => $co_inventors_formatted,
'rows' => $group['rows'],
];
From c28ec591beeb05c7b44764b03975794f680c1b29 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Sun, 14 Jun 2026 19:45:31 -0500
Subject: [PATCH 09/25] Rename citation attribute to patentCitation
---
app/ProfileData.php | 2 +-
resources/views/livewire/profile-data-cards/patents.blade.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/ProfileData.php b/app/ProfileData.php
index 16ff9065..23a353b6 100644
--- a/app/ProfileData.php
+++ b/app/ProfileData.php
@@ -191,7 +191,7 @@ public function getImageAttribute()
* - European countries sharing a patent number collapse into one "EPO - Number" entry
* using the earliest published_date among them
*/
- public function getCitationAttribute(): ?string
+ public function getPatentCitationAttribute(): ?string
{
if ($this->type !== 'patents') {
return null;
diff --git a/resources/views/livewire/profile-data-cards/patents.blade.php b/resources/views/livewire/profile-data-cards/patents.blade.php
index aa5a7b1f..2a84af08 100644
--- a/resources/views/livewire/profile-data-cards/patents.blade.php
+++ b/resources/views/livewire/profile-data-cards/patents.blade.php
@@ -2,7 +2,7 @@
Patents @if($editable) Edit @endif
@foreach($data as $patent)
- {{$patent->title}} - {{ $patent->citation }}.
+ {{$patent->title}} - {{ $patent->patentCitation }}.
@endforeach
@if($paginated)
From 0a5fe15262244f59067f35e020d467506fb94f4c Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Sun, 14 Jun 2026 19:51:17 -0500
Subject: [PATCH 10/25] Update label wording and form layout
---
.../views/profiles/edit/_patent_member_fields.blade.php | 2 +-
resources/views/profiles/edit/patents.blade.php | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index 43a28e5d..a62b7c09 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -60,7 +60,7 @@
-
+
Title (optional)
- Title
+ Patents Group Title (Enter an individual patent or a group of related patents)
- Members
+ Patents Information
0
- Add Member
+ Add New Patent to Group
-
From 13e52268f2abe388f52c60679e7036e668b1c3e8 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Fri, 10 Jul 2026 16:46:03 -0500
Subject: [PATCH 11/25] Move countries from settings to a helper class
---
app/Helpers/Country.php | 144 ++++++++++++++++++++
app/Http/Controllers/ProfilesController.php | 7 +-
app/ProfileData.php | 8 --
resources/views/settings.blade.php | 6 -
4 files changed, 145 insertions(+), 20 deletions(-)
create mode 100644 app/Helpers/Country.php
diff --git a/app/Helpers/Country.php b/app/Helpers/Country.php
new file mode 100644
index 00000000..dce41eea
--- /dev/null
+++ b/app/Helpers/Country.php
@@ -0,0 +1,144 @@
+
+ */
+ private const LIST = [
+ 'AR' => 'Argentina',
+ 'AU' => 'Australia',
+ 'AT' => 'Austria',
+ 'BD' => 'Bangladesh',
+ 'BE' => 'Belgium',
+ 'BO' => 'Bolivia',
+ 'BA' => 'Bosnia and Herzegovina',
+ 'BR' => 'Brazil',
+ 'BG' => 'Bulgaria',
+ 'CA' => 'Canada',
+ 'CL' => 'Chile',
+ 'CN' => 'China',
+ 'CO' => 'Colombia',
+ 'CR' => 'Costa Rica',
+ 'HR' => 'Croatia',
+ 'CY' => 'Cyprus',
+ 'CZ' => 'Czechia',
+ 'DK' => 'Denmark',
+ 'DO' => 'Dominican Republic',
+ 'EC' => 'Ecuador',
+ 'EG' => 'Egypt',
+ 'EE' => 'Estonia',
+ 'EP' => 'European',
+ 'FI' => 'Finland',
+ 'FR' => 'France',
+ 'GE' => 'Georgia',
+ 'DE' => 'Germany',
+ 'GH' => 'Ghana',
+ 'GR' => 'Greece',
+ 'GT' => 'Guatemala',
+ 'HK' => 'Hong Kong',
+ 'HU' => 'Hungary',
+ 'IS' => 'Iceland',
+ 'IN' => 'India',
+ 'ID' => 'Indonesia',
+ 'IE' => 'Ireland',
+ 'IL' => 'Israel',
+ 'IT' => 'Italy',
+ 'JM' => 'Jamaica',
+ 'JP' => 'Japan',
+ 'JO' => 'Jordan',
+ 'KZ' => 'Kazakhstan',
+ 'KE' => 'Kenya',
+ 'KR' => 'Korea (Republic of)',
+ 'KW' => 'Kuwait',
+ 'LV' => 'Latvia',
+ 'LB' => 'Lebanon',
+ 'LT' => 'Lithuania',
+ 'LU' => 'Luxembourg',
+ 'MY' => 'Malaysia',
+ 'MT' => 'Malta',
+ 'MX' => 'Mexico',
+ 'MA' => 'Morocco',
+ 'NL' => 'Netherlands',
+ 'NZ' => 'New Zealand',
+ 'NG' => 'Nigeria',
+ 'NO' => 'Norway',
+ 'PK' => 'Pakistan',
+ 'PA' => 'Panama',
+ 'PY' => 'Paraguay',
+ 'PE' => 'Peru',
+ 'PH' => 'Philippines',
+ 'PL' => 'Poland',
+ 'PT' => 'Portugal',
+ 'QA' => 'Qatar',
+ 'RO' => 'Romania',
+ 'RU' => 'Russia',
+ 'SA' => 'Saudi Arabia',
+ 'RS' => 'Serbia',
+ 'SG' => 'Singapore',
+ 'SK' => 'Slovakia',
+ 'SI' => 'Slovenia',
+ 'ZA' => 'South Africa',
+ 'ES' => 'Spain',
+ 'LK' => 'Sri Lanka',
+ 'SE' => 'Sweden',
+ 'CH' => 'Switzerland',
+ 'TW' => 'Taiwan',
+ 'TZ' => 'Tanzania',
+ 'TH' => 'Thailand',
+ 'TT' => 'Trinidad and Tobago',
+ 'TN' => 'Tunisia',
+ 'TR' => 'Turkey',
+ 'UA' => 'Ukraine',
+ 'AE' => 'United Arab Emirates',
+ 'GB' => 'United Kingdom',
+ 'US' => 'United States',
+ 'UY' => 'Uruguay',
+ 'VE' => 'Venezuela',
+ 'VN' => 'Vietnam',
+ ];
+
+ /**
+ * Return all jurisdictions keyed by code.
+ *
+ * @return array
+ */
+ public static function all(): array
+ {
+ return self::LIST;
+ }
+
+ /**
+ * Return all jurisdiction codes.
+ *
+ * @return list
+ */
+ public static function codes(): array
+ {
+ return array_keys(self::LIST);
+ }
+
+ /**
+ * Look up a jurisdiction name by code.
+ */
+ public static function name(string $code): ?string
+ {
+ return self::LIST[$code] ?? null;
+ }
+
+ /**
+ * Check whether a code is a known jurisdiction.
+ */
+ public static function has(string $code): bool
+ {
+ return array_key_exists($code, self::LIST);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/ProfilesController.php b/app/Http/Controllers/ProfilesController.php
index 06cac8ee..64fdd2ba 100644
--- a/app/Http/Controllers/ProfilesController.php
+++ b/app/Http/Controllers/ProfilesController.php
@@ -14,7 +14,6 @@
use App\Http\Requests\ProfileSearchRequest;
use App\Http\Requests\ProfileUpdateRequest;
use App\School;
-use App\Setting;
use Illuminate\Contracts\View\View as ViewContract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -254,11 +253,7 @@ public function edit(Profile $profile, string $section): View|ViewContract|Redir
$data->push($record);
}
- if ($section === 'patents') {
- $jurisdictions = ProfileData::jurisdictions();
- }
-
- return view('profiles.edit', compact('profile', 'section', 'data', 'jurisdictions'));
+ return view('profiles.edit', compact('profile', 'section', 'data'));
}
/**
diff --git a/app/ProfileData.php b/app/ProfileData.php
index 23a353b6..abfbc6fd 100644
--- a/app/ProfileData.php
+++ b/app/ProfileData.php
@@ -335,14 +335,6 @@ private function formatCitationDate(string $date_str): string
}
}
- public static function jurisdictions()
- {
- $patent_jurisdictions_setting = optional(Setting::whereName('patent_jurisdictions')->first())->value;
- $jurisdictions = $patent_jurisdictions_setting ? preg_split("/[\r\n]+/", $patent_jurisdictions_setting) : [];
-
- return collect($jurisdictions)->combine($jurisdictions);
- }
-
/**
* Return a sortable integer (YYYYMMDD) from a date string. Returns PHP_INT_MAX if unparseable.
*/
diff --git a/resources/views/settings.blade.php b/resources/views/settings.blade.php
index aba85329..db8db957 100644
--- a/resources/views/settings.blade.php
+++ b/resources/views/settings.blade.php
@@ -225,12 +225,6 @@ class="btn btn-link"
@endif
-
- Patent Jurisdictions
- One per line. Used on patents section edit view.
-
-
-
{!! Form::submit('Save', array('class' => 'btn btn-primary edit-button')) !!}
From cd510d5dcfcfd2075793a61b63fc780daa21c183 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Fri, 10 Jul 2026 16:48:01 -0500
Subject: [PATCH 12/25] Replace country name with country code for patent
jurisdiction
---
.../ImportPatentsToProfilesCommand.php | 2 +-
app/ProfileData.php | 20 +++++++++----------
app/Services/PatentFamilyGrouper.php | 13 ++++++------
.../edit/_patent_member_fields.blade.php | 19 +++++++++++-------
4 files changed, 29 insertions(+), 25 deletions(-)
diff --git a/app/Console/Commands/ImportPatentsToProfilesCommand.php b/app/Console/Commands/ImportPatentsToProfilesCommand.php
index af884e5d..71672d04 100644
--- a/app/Console/Commands/ImportPatentsToProfilesCommand.php
+++ b/app/Console/Commands/ImportPatentsToProfilesCommand.php
@@ -125,7 +125,7 @@ private function buildJsonPayload(array $entry): array
$members['patent_' . $i] = [
'patent_internal_id' => trim((string) ($r['Patent Internal ID'] ?? '')),
'patent_title' => trim((string) ($r['Patent Title'] ?? '')),
- 'jurisdiction' => trim((string) ($r['Country'] ?? '')) ?: null,
+ 'jurisdiction' => trim((string) ($r['Code'] ?? '')) ?: null,
'patent_no' => $patent_no,
'status' => 'published',
'filed_date' => null,
diff --git a/app/ProfileData.php b/app/ProfileData.php
index abfbc6fd..2b854998 100644
--- a/app/ProfileData.php
+++ b/app/ProfileData.php
@@ -11,6 +11,7 @@
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
+use App\Helpers\Country;
class ProfileData extends Model implements HasMedia, Auditable
{
@@ -188,7 +189,9 @@ public function getImageAttribute()
*
* - Granted members: "jurisdiction - Number (published_date)"
* - Pending members: "jurisdiction - pending - Number (filed_date)"
- * - European countries sharing a patent number collapse into one "EPO - Number" entry
+ * - A member's own patent_title prefixes its entry only when it differs from
+ * the group title after normalization (case/punctuation variants are omitted)
+ * - European jurisdictions sharing a patent number collapse into one "EPO - Number" entry
* using the earliest published_date among them
*/
public function getPatentCitationAttribute(): ?string
@@ -213,10 +216,7 @@ public function getPatentCitationAttribute(): ?string
$entries = [];
$ep_buckets = [];
- $european_countries = [
- 'European', 'Belgium', 'Switzerland', 'Germany', 'Spain', 'France',
- 'United Kingdom', 'Ireland', 'Italy', 'The Netherlands',
- ];
+ $european_codes = ['EP', 'BE', 'CH', 'DE', 'ES', 'FR', 'GB', 'IE', 'IT', 'NL'];
foreach ($members as $member) {
$jurisdiction = trim((string) ($member['jurisdiction'] ?? ''));
@@ -231,7 +231,7 @@ public function getPatentCitationAttribute(): ?string
if ($status === 'granted'
&& $patent_no !== ''
- && in_array($jurisdiction, $european_countries, true)) {
+ && in_array($jurisdiction, $european_codes, true)) {
$date_str = trim((string) ($member['published_date'] ?? ''));
$date_sortable = $this->dateSortable($date_str);
@@ -297,10 +297,10 @@ private function formatCitationMember(array $member): ?string
}
$jurisdiction_display = match ($jurisdiction) {
- 'European' => 'EPO',
- 'United States' => 'US',
- 'Korea (Republic of)' => 'Korea',
- default => $jurisdiction,
+ 'EP' => 'EPO',
+ 'US' => 'US',
+ 'KR' => 'Korea',
+ default => Country::name($jurisdiction) ?? $jurisdiction,
};
$date_field = $status === 'pending' ? 'filed_date' : 'published_date';
diff --git a/app/Services/PatentFamilyGrouper.php b/app/Services/PatentFamilyGrouper.php
index 1c370192..1e20d6d4 100644
--- a/app/Services/PatentFamilyGrouper.php
+++ b/app/Services/PatentFamilyGrouper.php
@@ -14,9 +14,8 @@ class PatentFamilyGrouper
'/^(\d+MTI)\d+/',
];
- private const EUROPEAN_COUNTRIES = [
- 'European', 'Belgium', 'Switzerland', 'Germany', 'Spain', 'France',
- 'United Kingdom', 'Ireland', 'Italy', 'The Netherlands',
+ private const EUROPEAN_CODES = [
+ 'EP', 'BE', 'CH', 'DE', 'ES', 'FR', 'GB', 'IE', 'IT', 'NL',
];
public function familyPrefix(string $patent_internal_id): string
@@ -75,8 +74,8 @@ public function groupStrict(array $rows): array
$country_part = '';
if ($include_country) {
- $country = trim((string) ($row['Country'] ?? ''));
- if (in_array($country, self::EUROPEAN_COUNTRIES, true)) {
+ $country = trim((string) ($row['Code'] ?? ''));
+ if (in_array($country, self::EUROPEAN_CODES, true)) {
$country = 'EP';
}
$country_part = '||' . $country;
@@ -122,7 +121,7 @@ private function buildCoInventorIndex(array $rows): array
foreach ($rows as $row) {
$prefix = $this->familyPrefix((string) ($row['Patent Internal ID'] ?? ''));
- $country = trim((string) ($row['Country'] ?? ''));
+ $country = trim((string) ($row['Code'] ?? ''));
$patent_no = trim((string) ($row['Patent No.'] ?? ''));
$inventor = trim((string) ($row['Inventor'] ?? ''));
@@ -165,7 +164,7 @@ private function buildEntry(array $group, array $co_inventor_index): array
$co_inventor_names = [];
foreach ($group['rows'] as $r) {
- $c = trim((string) ($r['Country'] ?? ''));
+ $c = trim((string) ($r['Code'] ?? ''));
$p = trim((string) ($r['Patent No.'] ?? ''));
$prefix = $this->familyPrefix((string) ($r['Patent Internal ID'] ?? ''));
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index a62b7c09..93412a3d 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -8,16 +8,21 @@
+ @use(App\Helpers\Country)
-
Jurisdiction
+
Jurisdiction
- Select a value
- @foreach($jurisdictions as $jurisdiction)
-
- {{ $jurisdiction }}
+ name="data[{{ $parent_id }}][data][members][patent_{{ $index }}][jurisdiction]"
+ required
+ >
+ Select a jurisdiction…
+ @foreach(Country::all() as $code => $name)
+
+ {{ $name }}
@endforeach
From e24b315a25fa07d4a2f0d61e3e1485f0aaa6583b Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Mon, 13 Jul 2026 09:26:06 -0500
Subject: [PATCH 13/25] Add day-level selection to datepicker
---
public/css/app.css | 4 ++++
public/js/app.js | 19 ++++++++++++++++++-
public/mix-manifest.json | 8 ++++----
resources/assets/js/app.js | 17 +++++++++++++++++
resources/assets/sass/_core.scss | 4 ++++
.../edit/_patent_member_fields.blade.php | 4 ++--
6 files changed, 49 insertions(+), 7 deletions(-)
diff --git a/public/css/app.css b/public/css/app.css
index be026771..6faca204 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -10357,6 +10357,10 @@ input[type=file]:invalid {
padding: 30px;
}
+.datepicker-dropdown {
+ padding: 0.75rem;
+}
+
#footer-container {
background-color: #919191;
border-top: 10px solid #154734;
diff --git a/public/js/app.js b/public/js/app.js
index 77d86f20..fc8501d1 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -46,6 +46,16 @@ var profiles = function ($, undefined) {
keepEmptyValues: true,
minViewMode: 1,
format: 'yyyy/mm'
+ },
+ day: {
+ autoclose: true,
+ assumeNearbyYear: true,
+ clearBtn: true,
+ forceParse: false,
+ keepEmptyValues: true,
+ minViewMode: 0,
+ startView: 0,
+ format: 'yyyy-mm-dd'
}
}
};
@@ -194,7 +204,7 @@ var profiles = function ($, undefined) {
var item_template = document.querySelector((_options$template = options.template) !== null && _options$template !== void 0 ? _options$template : 'form .record');
var item_container = (_document$querySelect = document.querySelector(options.insertInto)) !== null && _document$querySelect !== void 0 ? _document$querySelect : item_template.parentElement;
if (item_template) {
- var _new_item$querySelect, _new_item$querySelect2, _new_item$querySelect3, _new_item$querySelect4, _new_item$querySelect5, _new_item$querySelect6, _new_item$querySelect7, _new_item$querySelect8, _new_item$querySelect9, _new_item$querySelect0;
+ var _new_item$querySelect, _new_item$querySelect2, _new_item$querySelect3, _new_item$querySelect4, _new_item$querySelect5, _new_item$querySelect6, _new_item$querySelect7, _new_item$querySelect8, _new_item$querySelect9, _new_item$querySelect0, _new_item$querySelect1;
var old_id = item_template.dataset.rowId;
var new_id;
if (Number(item_container.dataset.nextRowId) >= 0) {
@@ -245,6 +255,9 @@ var profiles = function ($, undefined) {
(_new_item$querySelect0 = new_item.querySelectorAll('.datepicker.month')) === null || _new_item$querySelect0 === void 0 || _new_item$querySelect0.forEach(function (el) {
$(el).datepicker(config.datepicker.month);
});
+ (_new_item$querySelect1 = new_item.querySelectorAll('.datepicker.day')) === null || _new_item$querySelect1 === void 0 || _new_item$querySelect1.forEach(function (el) {
+ $(el).datepicker(config.datepicker.day);
+ });
$(new_item).hide();
if ('insertType' in options && options.insertType === 'prepend') {
item_container.prepend(new_item);
@@ -716,6 +729,9 @@ var profiles = function ($, undefined) {
subrow.querySelectorAll('.datepicker.month').forEach(function (el) {
$(el).datepicker(config.datepicker.month);
});
+ subrow.querySelectorAll('.datepicker.day').forEach(function (el) {
+ $(el).datepicker(config.datepicker.day);
+ });
};
/**
@@ -837,6 +853,7 @@ $(function () {
__webpack_require__(/*! bootstrap-datepicker */ "./node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js");
$('.datepicker.year').datepicker(profiles.config.datepicker.year);
$('.datepicker.month').datepicker(profiles.config.datepicker.month);
+ $('.datepicker.day').datepicker(profiles.config.datepicker.day);
//show preview of uploaded image
$('input[type="file"]').on('change', function (e) {
diff --git a/public/mix-manifest.json b/public/mix-manifest.json
index 595a2be2..132b01d2 100644
--- a/public/mix-manifest.json
+++ b/public/mix-manifest.json
@@ -1,10 +1,10 @@
{
- "/js/app.js": "/js/app.js?id=dcbb1b5b4604470fc46c6bcb17fd0e1b",
- "/js/app.js.map": "/js/app.js.map?id=b70c8cb2237f6e5d118c24f8401c1f6a",
+ "/js/app.js": "/js/app.js?id=5b6f186f994b7f5333e9e82ee81275f3",
+ "/js/app.js.map": "/js/app.js.map?id=6595e17777c7e09fd0aeeea934684f10",
"/js/manifest.js": "/js/manifest.js?id=dc9ead3d7857b522d7de22d75063453c",
"/js/manifest.js.map": "/js/manifest.js.map?id=389e00e7d7680b68d4e1d128ce27ff48",
- "/css/app.css": "/css/app.css?id=0fd161f323dd5c77642c3240bbb47d16",
- "/css/app.css.map": "/css/app.css.map?id=3ba6add83b449d9a830b1160dc25d43d",
+ "/css/app.css": "/css/app.css?id=91785da2d79f182c91a936248d44ee04",
+ "/css/app.css.map": "/css/app.css.map?id=497efd85a6125dd8d688142ab9fb8e0d",
"/js/vendor.js": "/js/vendor.js?id=77012e19e850a379f73e3ac0c76bc9b1",
"/js/vendor.js.map": "/js/vendor.js.map?id=f3f5514d1186aa088c887b6ebe999fe0"
}
diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js
index 0dd6a987..906e95dd 100644
--- a/resources/assets/js/app.js
+++ b/resources/assets/js/app.js
@@ -35,6 +35,16 @@ var profiles = (function ($, undefined) {
minViewMode: 1,
format: 'yyyy/mm',
},
+ day: {
+ autoclose: true,
+ assumeNearbyYear: true,
+ clearBtn: true,
+ forceParse: false,
+ keepEmptyValues: true,
+ minViewMode: 0,
+ startView: 0,
+ format: 'yyyy-mm-dd',
+ },
},
};
@@ -193,6 +203,9 @@ var profiles = (function ($, undefined) {
new_item.querySelectorAll('.datepicker.month')?.forEach((el) => {
$(el).datepicker(config.datepicker.month);
});
+ new_item.querySelectorAll('.datepicker.day')?.forEach((el) => {
+ $(el).datepicker(config.datepicker.day);
+ });
$(new_item).hide();
if ('insertType' in options && options.insertType === 'prepend') {
@@ -661,6 +674,9 @@ var profiles = (function ($, undefined) {
subrow.querySelectorAll('.datepicker.month').forEach((el) => {
$(el).datepicker(config.datepicker.month);
});
+ subrow.querySelectorAll('.datepicker.day').forEach((el) => {
+ $(el).datepicker(config.datepicker.day);
+ });
};
/**
@@ -798,6 +814,7 @@ $(function() {
require('bootstrap-datepicker');
$('.datepicker.year').datepicker(profiles.config.datepicker.year);
$('.datepicker.month').datepicker(profiles.config.datepicker.month);
+ $('.datepicker.day').datepicker(profiles.config.datepicker.day);
//show preview of uploaded image
$('input[type="file"]').on('change', (e) => profiles.preview_selected_image(e));
diff --git a/resources/assets/sass/_core.scss b/resources/assets/sass/_core.scss
index bcf1b015..ae4d5970 100644
--- a/resources/assets/sass/_core.scss
+++ b/resources/assets/sass/_core.scss
@@ -443,3 +443,7 @@ input[type="file"]:invalid{
.flash-container .flash-message {
padding: 30px;
}
+
+.datepicker-dropdown {
+ padding: 0.75rem;
+}
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index 93412a3d..6a0309bd 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -42,7 +42,7 @@
Filed Date
-
@@ -50,7 +50,7 @@
Published Date
-
From 849450d3539f53c10826e3efcf4dc1bc48345151 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Mon, 13 Jul 2026 10:54:54 -0500
Subject: [PATCH 14/25] Normalize member titles in citations to prevent
duplicate display
---
app/ProfileData.php | 65 ++++++++++++++++++++++++++++++++-------------
1 file changed, 47 insertions(+), 18 deletions(-)
diff --git a/app/ProfileData.php b/app/ProfileData.php
index 2b854998..17d8452f 100644
--- a/app/ProfileData.php
+++ b/app/ProfileData.php
@@ -183,9 +183,10 @@ public function getImageAttribute()
}
/**
- * Build a citation string from the patent's title and members.
+ * Build a citation string from the patent's members. The title is not
+ * included — the view renders it separately with its own format.
*
- * Format: "Title - jurisdiction - Number (mm/dd/yyyy), jurisdiction - pending - Number (mm/dd/yyyy), ..."
+ * Format: "jurisdiction - Number (mm/dd/yyyy), jurisdiction - pending - Number (mm/dd/yyyy), ..."
*
* - Granted members: "jurisdiction - Number (published_date)"
* - Pending members: "jurisdiction - pending - Number (filed_date)"
@@ -201,16 +202,12 @@ public function getPatentCitationAttribute(): ?string
}
$data = $this->data;
- $title = trim((string) ($data['title'] ?? ''));
$co_inventors = trim((string) ($data['co_inventors'] ?? ''));
+ $group_title_normalized = $this->normalizeCitationTitle((string) ($data['title'] ?? ''));
$members = $data['members'] ?? [];
if (!is_array($members) || empty($members)) {
- $base = $title !== '' ? $title : null;
- if ($base !== null && $co_inventors !== '') {
- return "{$base} - co-inventors: {$co_inventors}";
- }
- return $base;
+ return $co_inventors !== '' ? "co-inventors: {$co_inventors}" : null;
}
$entries = [];
@@ -239,21 +236,26 @@ public function getPatentCitationAttribute(): ?string
if (!isset($ep_buckets[$patent_no])) {
$ep_buckets[$patent_no] = [
'patent_no' => $patent_no,
+ 'title_display' => $this->variantTitle($member, $group_title_normalized),
'date_display' => $this->formatCitationDate($date_str),
'date_sortable' => $date_sortable,
];
} elseif ($date_sortable < $ep_buckets[$patent_no]['date_sortable']) {
+ $ep_buckets[$patent_no]['title_display'] = $this->variantTitle($member, $group_title_normalized);
$ep_buckets[$patent_no]['date_display'] = $this->formatCitationDate($date_str);
$ep_buckets[$patent_no]['date_sortable'] = $date_sortable;
}
continue;
}
- $entries[] = $this->formatCitationMember($member);
+ $entries[] = $this->formatCitationMember($member, $group_title_normalized);
}
foreach ($ep_buckets as $bucket) {
$entry = "EPO - {$bucket['patent_no']}";
+ if ($bucket['title_display'] !== '') {
+ $entry = "{$bucket['title_display']} - {$entry}";
+ }
if ($bucket['date_display'] !== '') {
$entry .= " ({$bucket['date_display']})";
}
@@ -263,16 +265,10 @@ public function getPatentCitationAttribute(): ?string
$entries = array_filter($entries, fn ($e) => $e !== null && $e !== '');
if (empty($entries)) {
- $base = $title !== '' ? $title : null;
- if ($base !== null && $co_inventors !== '') {
- return "{$base} - co-inventors: {$co_inventors}";
- }
- return $base;
+ return $co_inventors !== '' ? "co-inventors: {$co_inventors}" : null;
}
- $citation = $title !== ''
- ? "{$title} - " . implode(', ', $entries)
- : implode(', ', $entries);
+ $citation = implode(', ', $entries);
if ($co_inventors !== '') {
$citation .= " - co-inventors: {$co_inventors}";
@@ -284,7 +280,7 @@ public function getPatentCitationAttribute(): ?string
/**
* Format a single member as a citation entry.
*/
- private function formatCitationMember(array $member): ?string
+ private function formatCitationMember(array $member, string $group_title_normalized): ?string
{
$jurisdiction = trim((string) ($member['jurisdiction'] ?? ''));
$status = $member['status'] ?? 'granted';
@@ -309,6 +305,11 @@ private function formatCitationMember(array $member): ?string
$entry = $jurisdiction_display;
+ $variant_title = $this->variantTitle($member, $group_title_normalized);
+ if ($variant_title !== '') {
+ $entry = "{$variant_title} - {$entry}";
+ }
+
if ($status === 'pending') {
$entry .= ' - pending';
}
@@ -320,6 +321,34 @@ private function formatCitationMember(array $member): ?string
return $entry;
}
+ /**
+ * Return the member's own title when it's different from the group
+ * title, or '' when it matches after normalization (or is empty).
+ */
+ private function variantTitle(array $member, string $group_title_normalized): string
+ {
+ $member_title = trim((string) ($member['patent_title'] ?? ''));
+ if ($member_title === '') {
+ return '';
+ }
+
+ return $this->normalizeCitationTitle($member_title) === $group_title_normalized
+ ? ''
+ : $member_title;
+ }
+
+ /**
+ * Normalize a title for comparison: lowercase, strip punctuation,
+ * collapse whitespace. Mirrors PatentFamilyGrouper::normalizeTitle().
+ */
+ private function normalizeCitationTitle(string $title): string
+ {
+ $t = mb_strtolower(trim($title));
+ $t = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $t);
+ $t = preg_replace('/\s+/', ' ', $t);
+ return trim($t);
+ }
+
/**
* Format a date string as mm/dd/yyyy. Returns empty string if unparseable.
*/
From 25a4f24b8f9acd1294d8a2c10023bcb67bea8ab8 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Mon, 13 Jul 2026 11:26:40 -0500
Subject: [PATCH 15/25] Fix patent group expanded/collapsed icon status
---
public/css/app.css | 5 +++++
public/js/app.js | 10 ----------
public/mix-manifest.json | 8 ++++----
resources/assets/js/app.js | 11 -----------
resources/assets/sass/_core.scss | 5 +++++
resources/views/profiles/edit/patents.blade.php | 3 ++-
6 files changed, 16 insertions(+), 26 deletions(-)
diff --git a/public/css/app.css b/public/css/app.css
index 6faca204..f177f13f 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -10361,6 +10361,11 @@ input[type=file]:invalid {
padding: 0.75rem;
}
+[data-toggle-subrecords][aria-expanded=true] .when-collapsed,
+[data-toggle-subrecords][aria-expanded=false] .when-expanded {
+ display: none;
+}
+
#footer-container {
background-color: #919191;
border-top: 10px solid #154734;
diff --git a/public/js/app.js b/public/js/app.js
index fc8501d1..7b5010a3 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -645,11 +645,6 @@ var profiles = function ($, undefined) {
if (!subrecords) return;
var is_expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!is_expanded));
- var icon = button.querySelector('i');
- if (icon) {
- icon.classList.toggle('fa-chevron-right', is_expanded);
- icon.classList.toggle('fa-chevron-down', !is_expanded);
- }
if (is_expanded) {
$(subrecords).slideUp(200);
} else {
@@ -685,11 +680,6 @@ var profiles = function ($, undefined) {
var count = subrecords.querySelectorAll(':scope > .subrecord:not(.template)').length;
var should_expand = count > 0;
button.setAttribute('aria-expanded', String(should_expand));
- var icon = button.querySelector('i');
- if (icon) {
- icon.classList.toggle('fa-chevron-down', should_expand);
- icon.classList.toggle('fa-chevron-right', !should_expand);
- }
subrecords.style.display = should_expand ? 'block' : 'none';
};
diff --git a/public/mix-manifest.json b/public/mix-manifest.json
index 132b01d2..807cd567 100644
--- a/public/mix-manifest.json
+++ b/public/mix-manifest.json
@@ -1,10 +1,10 @@
{
- "/js/app.js": "/js/app.js?id=5b6f186f994b7f5333e9e82ee81275f3",
- "/js/app.js.map": "/js/app.js.map?id=6595e17777c7e09fd0aeeea934684f10",
+ "/js/app.js": "/js/app.js?id=bc34d7a3d80045d2c318e778fac7c9d5",
+ "/js/app.js.map": "/js/app.js.map?id=e8623e0d43c9558ba9bdfd0423918fa2",
"/js/manifest.js": "/js/manifest.js?id=dc9ead3d7857b522d7de22d75063453c",
"/js/manifest.js.map": "/js/manifest.js.map?id=389e00e7d7680b68d4e1d128ce27ff48",
- "/css/app.css": "/css/app.css?id=91785da2d79f182c91a936248d44ee04",
- "/css/app.css.map": "/css/app.css.map?id=497efd85a6125dd8d688142ab9fb8e0d",
+ "/css/app.css": "/css/app.css?id=4b9dbdee54b7e4035462372a6fc866dc",
+ "/css/app.css.map": "/css/app.css.map?id=452ab8017232484974bcc6076c461ec2",
"/js/vendor.js": "/js/vendor.js?id=77012e19e850a379f73e3ac0c76bc9b1",
"/js/vendor.js.map": "/js/vendor.js.map?id=f3f5514d1186aa088c887b6ebe999fe0"
}
diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js
index 906e95dd..94858866 100644
--- a/resources/assets/js/app.js
+++ b/resources/assets/js/app.js
@@ -583,12 +583,6 @@ var profiles = (function ($, undefined) {
const is_expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!is_expanded));
- const icon = button.querySelector('i');
- if (icon) {
- icon.classList.toggle('fa-chevron-right', is_expanded);
- icon.classList.toggle('fa-chevron-down', !is_expanded);
- }
-
if (is_expanded) {
$(subrecords).slideUp(200);
} else {
@@ -627,11 +621,6 @@ var profiles = (function ($, undefined) {
const should_expand = count > 0;
button.setAttribute('aria-expanded', String(should_expand));
- const icon = button.querySelector('i');
- if (icon) {
- icon.classList.toggle('fa-chevron-down', should_expand);
- icon.classList.toggle('fa-chevron-right', !should_expand);
- }
subrecords.style.display = should_expand ? 'block' : 'none';
};
diff --git a/resources/assets/sass/_core.scss b/resources/assets/sass/_core.scss
index ae4d5970..bf7068c1 100644
--- a/resources/assets/sass/_core.scss
+++ b/resources/assets/sass/_core.scss
@@ -447,3 +447,8 @@ input[type="file"]:invalid{
.datepicker-dropdown {
padding: 0.75rem;
}
+
+[data-toggle-subrecords][aria-expanded="true"] .when-collapsed,
+[data-toggle-subrecords][aria-expanded="false"] .when-expanded {
+ display: none;
+}
diff --git a/resources/views/profiles/edit/patents.blade.php b/resources/views/profiles/edit/patents.blade.php
index 4b13e3ee..79d5e395 100644
--- a/resources/views/profiles/edit/patents.blade.php
+++ b/resources/views/profiles/edit/patents.blade.php
@@ -45,7 +45,8 @@
class="btn btn-sm btn-link p-0 mr-3"
aria-expanded="false"
data-toggle-subrecords>
-
+
+
Patents Information
0
From 4eb12cd9af08401ac12cccff39c99af13a4eca7d Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Mon, 13 Jul 2026 11:50:41 -0500
Subject: [PATCH 16/25] Patent members edit view padding adjustments
---
.../profiles/edit/_patent_member_fields.blade.php | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index 6a0309bd..14549c2b 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -1,7 +1,7 @@
Number
-
@@ -11,7 +11,7 @@
@use(App\Helpers\Country)
Jurisdiction
-
Status
-
@php $current_status = $member['status'] ?? 'published'; @endphp
@@ -42,7 +42,7 @@
Filed Date
-
@@ -50,7 +50,7 @@
Published Date
-
@@ -65,9 +65,9 @@
-
+
Title (optional)
-
From 29b49e51e1a4a523ec0e01c34781c2fbe4403520 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:40:43 -0500
Subject: [PATCH 17/25] Revert changes in the datepicker JS function to use
bootstrap datepicker attribute for day-level
---
public/js/app.js | 19 +------------------
public/mix-manifest.json | 4 ++--
resources/assets/js/app.js | 17 -----------------
.../edit/_patent_member_fields.blade.php | 10 ++++++++--
4 files changed, 11 insertions(+), 39 deletions(-)
diff --git a/public/js/app.js b/public/js/app.js
index 7b5010a3..8daa527f 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -46,16 +46,6 @@ var profiles = function ($, undefined) {
keepEmptyValues: true,
minViewMode: 1,
format: 'yyyy/mm'
- },
- day: {
- autoclose: true,
- assumeNearbyYear: true,
- clearBtn: true,
- forceParse: false,
- keepEmptyValues: true,
- minViewMode: 0,
- startView: 0,
- format: 'yyyy-mm-dd'
}
}
};
@@ -204,7 +194,7 @@ var profiles = function ($, undefined) {
var item_template = document.querySelector((_options$template = options.template) !== null && _options$template !== void 0 ? _options$template : 'form .record');
var item_container = (_document$querySelect = document.querySelector(options.insertInto)) !== null && _document$querySelect !== void 0 ? _document$querySelect : item_template.parentElement;
if (item_template) {
- var _new_item$querySelect, _new_item$querySelect2, _new_item$querySelect3, _new_item$querySelect4, _new_item$querySelect5, _new_item$querySelect6, _new_item$querySelect7, _new_item$querySelect8, _new_item$querySelect9, _new_item$querySelect0, _new_item$querySelect1;
+ var _new_item$querySelect, _new_item$querySelect2, _new_item$querySelect3, _new_item$querySelect4, _new_item$querySelect5, _new_item$querySelect6, _new_item$querySelect7, _new_item$querySelect8, _new_item$querySelect9, _new_item$querySelect0;
var old_id = item_template.dataset.rowId;
var new_id;
if (Number(item_container.dataset.nextRowId) >= 0) {
@@ -255,9 +245,6 @@ var profiles = function ($, undefined) {
(_new_item$querySelect0 = new_item.querySelectorAll('.datepicker.month')) === null || _new_item$querySelect0 === void 0 || _new_item$querySelect0.forEach(function (el) {
$(el).datepicker(config.datepicker.month);
});
- (_new_item$querySelect1 = new_item.querySelectorAll('.datepicker.day')) === null || _new_item$querySelect1 === void 0 || _new_item$querySelect1.forEach(function (el) {
- $(el).datepicker(config.datepicker.day);
- });
$(new_item).hide();
if ('insertType' in options && options.insertType === 'prepend') {
item_container.prepend(new_item);
@@ -719,9 +706,6 @@ var profiles = function ($, undefined) {
subrow.querySelectorAll('.datepicker.month').forEach(function (el) {
$(el).datepicker(config.datepicker.month);
});
- subrow.querySelectorAll('.datepicker.day').forEach(function (el) {
- $(el).datepicker(config.datepicker.day);
- });
};
/**
@@ -843,7 +827,6 @@ $(function () {
__webpack_require__(/*! bootstrap-datepicker */ "./node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js");
$('.datepicker.year').datepicker(profiles.config.datepicker.year);
$('.datepicker.month').datepicker(profiles.config.datepicker.month);
- $('.datepicker.day').datepicker(profiles.config.datepicker.day);
//show preview of uploaded image
$('input[type="file"]').on('change', function (e) {
diff --git a/public/mix-manifest.json b/public/mix-manifest.json
index 807cd567..518f5fb5 100644
--- a/public/mix-manifest.json
+++ b/public/mix-manifest.json
@@ -1,6 +1,6 @@
{
- "/js/app.js": "/js/app.js?id=bc34d7a3d80045d2c318e778fac7c9d5",
- "/js/app.js.map": "/js/app.js.map?id=e8623e0d43c9558ba9bdfd0423918fa2",
+ "/js/app.js": "/js/app.js?id=8868c2a9082e11682a64fec028e549c3",
+ "/js/app.js.map": "/js/app.js.map?id=a5bf329df0725cc39848d5ce5a73a941",
"/js/manifest.js": "/js/manifest.js?id=dc9ead3d7857b522d7de22d75063453c",
"/js/manifest.js.map": "/js/manifest.js.map?id=389e00e7d7680b68d4e1d128ce27ff48",
"/css/app.css": "/css/app.css?id=4b9dbdee54b7e4035462372a6fc866dc",
diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js
index 94858866..47c89ddf 100644
--- a/resources/assets/js/app.js
+++ b/resources/assets/js/app.js
@@ -35,16 +35,6 @@ var profiles = (function ($, undefined) {
minViewMode: 1,
format: 'yyyy/mm',
},
- day: {
- autoclose: true,
- assumeNearbyYear: true,
- clearBtn: true,
- forceParse: false,
- keepEmptyValues: true,
- minViewMode: 0,
- startView: 0,
- format: 'yyyy-mm-dd',
- },
},
};
@@ -203,9 +193,6 @@ var profiles = (function ($, undefined) {
new_item.querySelectorAll('.datepicker.month')?.forEach((el) => {
$(el).datepicker(config.datepicker.month);
});
- new_item.querySelectorAll('.datepicker.day')?.forEach((el) => {
- $(el).datepicker(config.datepicker.day);
- });
$(new_item).hide();
if ('insertType' in options && options.insertType === 'prepend') {
@@ -663,9 +650,6 @@ var profiles = (function ($, undefined) {
subrow.querySelectorAll('.datepicker.month').forEach((el) => {
$(el).datepicker(config.datepicker.month);
});
- subrow.querySelectorAll('.datepicker.day').forEach((el) => {
- $(el).datepicker(config.datepicker.day);
- });
};
/**
@@ -803,7 +787,6 @@ $(function() {
require('bootstrap-datepicker');
$('.datepicker.year').datepicker(profiles.config.datepicker.year);
$('.datepicker.month').datepicker(profiles.config.datepicker.month);
- $('.datepicker.day').datepicker(profiles.config.datepicker.day);
//show preview of uploaded image
$('input[type="file"]').on('change', (e) => profiles.preview_selected_image(e));
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index 14549c2b..528fa0d1 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -42,17 +42,23 @@
Filed Date
-
Published Date
-
From 2e620230c33aab437e3f6098d31fc998a08765a1 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:22:08 -0500
Subject: [PATCH 18/25] Add missing hidden patent_internal_id to patent members
form
---
resources/views/profiles/edit/_patent_member_fields.blade.php | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index 528fa0d1..ab26ea42 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -1,4 +1,8 @@
+
+
Number
Date: Thu, 23 Jul 2026 16:23:28 -0500
Subject: [PATCH 19/25] Fix patent status name inconsistency
(granted->published)
---
app/ProfileData.php | 2 +-
resources/views/profiles/edit/_patent_member_fields.blade.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/ProfileData.php b/app/ProfileData.php
index 17d8452f..6b3563de 100644
--- a/app/ProfileData.php
+++ b/app/ProfileData.php
@@ -283,7 +283,7 @@ public function getPatentCitationAttribute(): ?string
private function formatCitationMember(array $member, string $group_title_normalized): ?string
{
$jurisdiction = trim((string) ($member['jurisdiction'] ?? ''));
- $status = $member['status'] ?? 'granted';
+ $status = $member['status'] ?? 'published';
$patent_no = trim((string) ($member['patent_no'] ?? ''));
$internal_id = trim((string) ($member['patent_internal_id'] ?? ''));
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index ab26ea42..27426d79 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -38,7 +38,7 @@
id="data[{{ $parent_id }}][data][members][patent_{{ $index }}][status]"
name="data[{{ $parent_id }}][data][members][patent_{{ $index }}][status]">
@php $current_status = $member['status'] ?? 'published'; @endphp
- Published
+ Published
Pending
Expired
From 584e187a4902551abb792ea5319f16d2ce25bf03 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:24:31 -0500
Subject: [PATCH 20/25] Fix title field key for patent members
---
.../profiles/edit/_patent_member_fields.blade.php | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index 27426d79..1bbb02b0 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -76,10 +76,10 @@
- Title (optional)
-
+ Title (optional — only shown in the citation if different from the group title)
+
\ No newline at end of file
From 0be19c58c1cb7c36d446eb60bcaaa8750e886893 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:26:02 -0500
Subject: [PATCH 21/25] Fix filed and published dates format inconsistencies
---
.../ImportPatentsToProfilesCommand.php | 13 +++++++-
.../edit/_patent_member_fields.blade.php | 30 +++++++++----------
2 files changed, 27 insertions(+), 16 deletions(-)
diff --git a/app/Console/Commands/ImportPatentsToProfilesCommand.php b/app/Console/Commands/ImportPatentsToProfilesCommand.php
index 71672d04..dd91ee71 100644
--- a/app/Console/Commands/ImportPatentsToProfilesCommand.php
+++ b/app/Console/Commands/ImportPatentsToProfilesCommand.php
@@ -129,7 +129,7 @@ private function buildJsonPayload(array $entry): array
'patent_no' => $patent_no,
'status' => 'published',
'filed_date' => null,
- 'published_date' => $this->parseDate((string) ($r['Filed Date'] ?? '')),
+ 'published_date' => $this->toStoredDate((string) ($r['Filed Date'] ?? '')),
];
$i++;
}
@@ -149,4 +149,15 @@ private function resolvePath(string $path): string
}
return base_path($path);
}
+
+ /**
+ * Parse an import date and return it in the same m/d/Y form the edit
+ * form's datepicker submits, so imported and hand-edited records share
+ * one stored format. Reuses parseDate() for parsing/validation.
+ */
+ private function toStoredDate(string $value): ?string
+ {
+ $iso = $this->parseDate($value);
+ return $iso === null ? null : \Carbon\Carbon::createFromFormat('Y-m-d', $iso)->format('m/d/Y');
+ }
}
\ No newline at end of file
diff --git a/resources/views/profiles/edit/_patent_member_fields.blade.php b/resources/views/profiles/edit/_patent_member_fields.blade.php
index 1bbb02b0..b7d4f43c 100644
--- a/resources/views/profiles/edit/_patent_member_fields.blade.php
+++ b/resources/views/profiles/edit/_patent_member_fields.blade.php
@@ -47,24 +47,24 @@
Filed Date
+ id="data[{{ $parent_id }}][data][members][patent_{{ $index }}][filed_date]"
+ name="data[{{ $parent_id }}][data][members][patent_{{ $index }}][filed_date]"
+ data-provide="datepicker"
+ data-date-format="mm/dd/yyyy"
+ data-date-autoclose="true"
+ value="{{ !empty($member['filed_date']) ? \Carbon\Carbon::parse($member['filed_date'])->format('m/d/Y') : '' }}">
- Published Date
-
-
+
Published Date
+
+
From a7d796a12b5354159f1bc7d3067985c9997f7b9c Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:27:06 -0500
Subject: [PATCH 22/25] fix visual gap between sub-records excluding spacing
after the last element
---
public/css/app.css | 8 ++++++++
public/mix-manifest.json | 4 ++--
resources/assets/sass/_core.scss | 3 +++
3 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/public/css/app.css b/public/css/app.css
index f177f13f..83d2d676 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -10366,6 +10366,14 @@ input[type=file]:invalid {
display: none;
}
+.subrecords > .subrecord:not(:last-child) {
+ margin-bottom: 3rem;
+}
+
+.subrecords > .subrecord {
+ margin-bottom: 0;
+}
+
#footer-container {
background-color: #919191;
border-top: 10px solid #154734;
diff --git a/public/mix-manifest.json b/public/mix-manifest.json
index 518f5fb5..0d66b104 100644
--- a/public/mix-manifest.json
+++ b/public/mix-manifest.json
@@ -3,8 +3,8 @@
"/js/app.js.map": "/js/app.js.map?id=a5bf329df0725cc39848d5ce5a73a941",
"/js/manifest.js": "/js/manifest.js?id=dc9ead3d7857b522d7de22d75063453c",
"/js/manifest.js.map": "/js/manifest.js.map?id=389e00e7d7680b68d4e1d128ce27ff48",
- "/css/app.css": "/css/app.css?id=4b9dbdee54b7e4035462372a6fc866dc",
- "/css/app.css.map": "/css/app.css.map?id=452ab8017232484974bcc6076c461ec2",
+ "/css/app.css": "/css/app.css?id=8628443a7e5445f7b3ab489f46dfa691",
+ "/css/app.css.map": "/css/app.css.map?id=c12371b1bfeab7edbd21a8749fa203cc",
"/js/vendor.js": "/js/vendor.js?id=77012e19e850a379f73e3ac0c76bc9b1",
"/js/vendor.js.map": "/js/vendor.js.map?id=f3f5514d1186aa088c887b6ebe999fe0"
}
diff --git a/resources/assets/sass/_core.scss b/resources/assets/sass/_core.scss
index bf7068c1..a41e7f6c 100644
--- a/resources/assets/sass/_core.scss
+++ b/resources/assets/sass/_core.scss
@@ -452,3 +452,6 @@ input[type="file"]:invalid{
[data-toggle-subrecords][aria-expanded="false"] .when-expanded {
display: none;
}
+
+.subrecords > .subrecord:not(:last-child) { margin-bottom: 3.0rem; }
+.subrecords > .subrecord { margin-bottom: 0; }
From 0ae4d12d8271ab08af6445006c79cea54de6babc Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Thu, 23 Jul 2026 16:30:45 -0500
Subject: [PATCH 23/25] Add patents validation rules
---
app/Http/Requests/ProfileUpdateRequest.php | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php
index 8b805874..3521b112 100644
--- a/app/Http/Requests/ProfileUpdateRequest.php
+++ b/app/Http/Requests/ProfileUpdateRequest.php
@@ -5,6 +5,7 @@
use App\Http\Requests\Concerns\HasImageUploads;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
+use App\Helpers\Country;
class ProfileUpdateRequest extends FormRequest
{
@@ -87,6 +88,9 @@ public function attributes()
'data.*.data.secondary_url' => 'Secondary URL',
'data.*.data.tertiary_url' => 'Tertiary URL',
'data.*.data.orc_id' => 'ORCID',
+ 'data.*.data.members.*.jurisdiction' => 'Jurisdiction',
+ 'data.*.data.members.*.status' => 'Status',
+ 'data.*.data.members.*.patent_no' => 'Patent number',
];
}
@@ -108,4 +112,16 @@ public function dataPresenceRules(): array
})
];
}
+
+ public function patentsRules() : array {
+ return [
+ 'data.*.data.title' => ['required', 'string', 'max:120'],
+ 'data.*.data.members' => ['nullable', 'array'],
+
+ 'data.*.data.members.*.patent_no' => ['required', 'string', 'max:50'],
+ 'data.*.data.members.*.status' => ['required', Rule::in(['published', 'pending', 'expired'])],
+ 'data.*.data.members.*.jurisdiction' => ['required', Rule::in(Country::codes())],
+ ];
+ }
+
}
From e917bf0e1155c0245720b8bb228ebab79359a5b0 Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Fri, 24 Jul 2026 12:29:07 -0500
Subject: [PATCH 24/25] Include the patents section in the profile pagination
tests
---
database/factories/ProfileDataFactory.php | 48 +++++++++++++++++--
.../Feature/Livewire/ProfileDataCardTest.php | 7 +--
2 files changed, 49 insertions(+), 6 deletions(-)
diff --git a/database/factories/ProfileDataFactory.php b/database/factories/ProfileDataFactory.php
index d2e83897..960abce9 100644
--- a/database/factories/ProfileDataFactory.php
+++ b/database/factories/ProfileDataFactory.php
@@ -128,7 +128,7 @@ public function affiliations()
return [
'type' => 'affiliations',
'data' => [
- 'tittle' => $this->faker->sentence(),
+ 'title' => $this->faker->sentence(),
'description' => $this->faker->sentence(),
'start_date' => $this->faker->year(),
'end_date' => $this->faker->year(),
@@ -148,7 +148,7 @@ public function support()
return [
'type' => 'support',
'data' => [
- 'tittle' => $this->faker->sentence(),
+ 'title' => $this->faker->sentence(),
'sponsor' => $this->faker->company(),
'amount' => $this->faker->randomNumber(5, true),
'description' => $this->faker->sentence(),
@@ -170,7 +170,7 @@ public function news()
return [
'type' => 'news',
'data' => [
- 'tittle' => $this->faker->sentence(),
+ 'title' => $this->faker->sentence(),
'url' => $this->faker->url(),
'description' => $this->faker->sentence(),
'start_date' => $this->faker->year(),
@@ -179,4 +179,46 @@ public function news()
];
});
}
+
+ /**
+ * Data Type "patents"
+ *
+ * @return \Illuminate\Database\Eloquent\Factories\Factory
+ */
+ public function patents()
+ {
+ return $this->state(function (array $attributes) {
+ $title = $this->faker->sentence();
+
+ return [
+ 'type' => 'patents',
+ 'data' => [
+ 'title' => $title,
+ 'co_inventors' => '',
+ 'family_prefix' => (string) $this->faker->numberBetween(10000, 99999),
+ 'members' => [
+ 'patent_1' => [
+ 'patent_internal_id' => $this->faker->numerify('#####US#'),
+ 'patent_title' => $title,
+ 'jurisdiction' => 'US',
+ 'patent_no' => number_format($this->faker->numberBetween(7_000_000, 11_999_999)),
+ 'status' => 'published',
+ 'filed_date' => null,
+ 'published_date' => $this->faker->date('m/d/Y'),
+ ],
+ 'patent_2' => [
+ 'patent_internal_id' => $this->faker->numerify('#####JP#'),
+ 'patent_title' => $title,
+ 'jurisdiction' => 'JP',
+ 'patent_no' => (string) $this->faker->numberBetween(5_000_000, 6_999_999),
+ 'status' => 'published',
+ 'filed_date' => null,
+ 'published_date' => $this->faker->date('m/d/Y'),
+ ],
+ ],
+ ],
+ ];
+ });
+ }
}
+
diff --git a/tests/Feature/Livewire/ProfileDataCardTest.php b/tests/Feature/Livewire/ProfileDataCardTest.php
index 3206d7bc..9e536cc4 100644
--- a/tests/Feature/Livewire/ProfileDataCardTest.php
+++ b/tests/Feature/Livewire/ProfileDataCardTest.php
@@ -31,17 +31,18 @@ class ProfileDataCardTest extends TestCase
*/
public function testPaginatedItemsOnFirstAndLastPage()
{
- $sections = [ 'publications', 'presentations', 'projects', 'additionals' ];
+ $sections = [ 'publications', 'presentations', 'projects', 'additionals', 'patents' ];
$profile = Profile::factory()
->hasData()
->has(ProfileData::factory()
- ->count(30)
+ ->count(40)
->sequence(
['type' => 'presentations'],
['type' => 'publications'],
['type' => 'projects'],
- ['type' => 'additionals']
+ ['type' => 'additionals'],
+ ['type' => 'patents']
)
->general(),'data')
->create();
From ffcf2c52ff5628acb24d7496e97ed017e40e80da Mon Sep 17 00:00:00 2001
From: Betsy Castro <5490820+betsyecastro@users.noreply.github.com>
Date: Fri, 24 Jul 2026 12:29:50 -0500
Subject: [PATCH 25/25] Add ProfileSectionsTest class and patents section test
---
tests/Feature/ProfileSectionsTest.php | 134 ++++++++++++++++++++++++++
1 file changed, 134 insertions(+)
create mode 100644 tests/Feature/ProfileSectionsTest.php
diff --git a/tests/Feature/ProfileSectionsTest.php b/tests/Feature/ProfileSectionsTest.php
new file mode 100644
index 00000000..38f12b21
--- /dev/null
+++ b/tests/Feature/ProfileSectionsTest.php
@@ -0,0 +1,134 @@
+hasData()->create();
+ $profile_data = ProfileData::factory()->patents()->create(['profile_id' => $profile->id]);
+
+ $edit_route = route('profiles.edit', ['profile' => $profile, 'section' => 'patents']);
+
+ // Guests are redirected to the login page
+ $this->get($edit_route)->assertRedirect(route('login'));
+
+ $this->loginAsAdmin();
+
+ $this->get($edit_route)
+ ->assertStatus(200)
+ ->assertViewIs('profiles.edit')
+ ->assertSeeTextInOrder(['Edit', $profile->name, 'Patents'])
+ ->assertSee($profile_data->data['title']);
+
+ $new_data = ProfileData::factory()->patents()->make()->data;
+ $us = $new_data['members']['patent_1'];
+ $jp = $new_data['members']['patent_2'];
+
+ $update_route = route('profiles.update', ['profile' => $profile, 'section' => 'patents']);
+
+ $response = $this->followingRedirects()->post($update_route, [
+ 'data' => [
+ $profile_data->id => [
+ 'id' => $profile_data->id,
+ 'data' => $new_data,
+ ],
+ ],
+ 'public' => 1,
+ ]);
+
+ $response
+ ->assertSessionHasNoErrors()
+ ->assertStatus(200)
+ ->assertViewIs('profiles.show')
+ ->assertSee('Profile updated.')
+ ->assertSeeText($new_data['title'])
+ ->assertSeeText($us['patent_no'])
+ ->assertSeeText($us['published_date']) // m/d/Y survives the round-trip
+ ->assertSeeText($jp['patent_no'])
+ ->assertSeeText($jp['published_date']);
+
+ $profile_data->refresh();
+ $this->assertSame($us['published_date'], $profile_data->data['members']['patent_1']['published_date']);
+ $this->assertSame('US', $profile_data->data['members']['patent_1']['jurisdiction']);
+ $this->assertSame('JP', $profile_data->data['members']['patent_2']['jurisdiction']);
+ }
+
+ /**
+ * The jurisdiction must be one of the valid countries.
+ */
+ public function testProfilePatentsEditFailsWithoutJurisdiction(): void
+ {
+ $profile = Profile::factory()->create();
+ $profile_data = ProfileData::factory()->patents()->create(['profile_id' => $profile->id]);
+
+ $this->loginAsAdmin();
+
+ $bad_data = ProfileData::factory()->patents()->make()->data;
+ $bad_data['members']['patent_1']['jurisdiction'] = ''; // the invalid field
+
+ $update_route = route('profiles.update', ['profile' => $profile, 'section' => 'patents']);
+
+ $this->post($update_route, [
+ 'data' => [
+ $profile_data->id => [
+ 'id' => $profile_data->id,
+ 'data' => $bad_data,
+ ],
+ ],
+ 'public' => 1,
+ ])->assertSessionHasErrors("data.{$profile_data->id}.data.members.patent_1.jurisdiction");
+ }
+
+ /**
+ * Verify the status whitelist: the rules allow only published/pending/expired.
+ */
+ public function testProfilePatentsEditRejectsOtherStatus(): void
+ {
+ $profile = Profile::factory()->create();
+ $profile_data = ProfileData::factory()->patents()->create(['profile_id' => $profile->id]);
+
+ $this->loginAsAdmin();
+
+ $data_with_wrong_status = ProfileData::factory()->patents()->make()->data;
+ $data_with_wrong_status['members']['patent_1']['status'] = 'other'; // not in the whitelist
+
+ $update_route = route('profiles.update', ['profile' => $profile, 'section' => 'patents']);
+
+ $this->post($update_route, [
+ 'data' => [
+ $profile_data->id => [
+ 'id' => $profile_data->id,
+ 'data' => $data_with_wrong_status,
+ ],
+ ],
+ 'public' => 1,
+ ])->assertSessionHasErrors("data.{$profile_data->id}.data.members.patent_1.status");
+ }
+}
\ No newline at end of file