diff --git a/hubzilla/addon/assoc_profile/assoc_fields.json.template b/hubzilla/addon/assoc_profile/assoc_fields.json.template new file mode 100644 index 0000000..72c0944 --- /dev/null +++ b/hubzilla/addon/assoc_profile/assoc_fields.json.template @@ -0,0 +1,269 @@ +{ + "_note": "Copy to assoc_fields.json on the host. Do not commit assoc_fields.json. This file is the canonical field definition for all association registry entries. Adding a field here and saving via the management interface will backfill all existing associations with the default value.", + "_version": "1.0", + "groups": [ + "Identity", + "Physical Structure", + "Governance and Management", + "Legal and Compliance", + "Record" + ], + "fields": [ + { + "nickname": "assoc_legal_name", + "label": "Legal Name", + "type": "text", + "group": "Identity", + "default": "", + "required": true, + "help": "Full legal name as recorded with Illinois Secretary of State" + }, + { + "nickname": "assoc_type", + "label": "Association Type", + "type": "select", + "group": "Identity", + "default": "UNK", + "options": ["Condominium", "Master", "CIC", "Unincorporated", "UNK", "Disputed"], + "required": true, + "help": "VS-01 verified value" + }, + { + "nickname": "assoc_statute", + "label": "Governing Statute", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "e.g. 765 ILCS 605 — populated after VS-01 verification" + }, + { + "nickname": "assoc_street", + "label": "Street Address", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "Physical address of the property" + }, + { + "nickname": "assoc_city", + "label": "City", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "" + }, + { + "nickname": "assoc_county", + "label": "County", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "e.g. Cook County" + }, + { + "nickname": "assoc_state", + "label": "State", + "type": "text", + "group": "Identity", + "default": "IL", + "required": false, + "help": "" + }, + { + "nickname": "assoc_zip", + "label": "ZIP Code", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "" + }, + { + "nickname": "assoc_placekey", + "label": "Placekey", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "Permanent geographic anchor — e.g. 247-222@5sb-8bs-y35" + }, + { + "nickname": "assoc_website", + "label": "Association Website", + "type": "text", + "group": "Identity", + "default": "", + "required": false, + "help": "URL of association-managed site if any" + }, + { + "nickname": "assoc_buildings", + "label": "Number of Buildings", + "type": "text", + "group": "Physical Structure", + "default": "UNK", + "required": false, + "help": "Integer or UNK — VS-02" + }, + { + "nickname": "assoc_units", + "label": "Number of Units", + "type": "text", + "group": "Physical Structure", + "default": "UNK", + "required": false, + "help": "Integer or UNK — VS-02" + }, + { + "nickname": "assoc_year_built", + "label": "Year Built", + "type": "text", + "group": "Physical Structure", + "default": "", + "required": false, + "help": "e.g. 1974" + }, + { + "nickname": "assoc_stories", + "label": "Stories Per Building", + "type": "text", + "group": "Physical Structure", + "default": "", + "required": false, + "help": "e.g. 3" + }, + { + "nickname": "assoc_mgmt_company", + "label": "Management Company", + "type": "text", + "group": "Governance and Management", + "default": "", + "required": false, + "help": "" + }, + { + "nickname": "assoc_mgmt_contact", + "label": "Management Contact", + "type": "text", + "group": "Governance and Management", + "default": "", + "required": false, + "help": "Name and email of primary contact" + }, + { + "nickname": "assoc_mgmt_certified", + "label": "Manager IDFPR Certified", + "type": "select", + "group": "Governance and Management", + "default": "UNK", + "options": ["Yes", "No", "UNK"], + "required": false, + "help": "VS-03" + }, + { + "nickname": "assoc_cai_listing", + "label": "CAI Directory Listing", + "type": "text", + "group": "Governance and Management", + "default": "", + "required": false, + "help": "How the association or its management company appears in CAI — member, vendor, AAMC, etc." + }, + { + "nickname": "assoc_bbb_standing", + "label": "BBB Standing", + "type": "text", + "group": "Governance and Management", + "default": "", + "required": false, + "help": "Better Business Bureau rating and status for the management company" + }, + { + "nickname": "assoc_idfpr_complaint", + "label": "IDFPR Complaints", + "type": "text", + "group": "Governance and Management", + "default": "", + "required": false, + "help": "Any complaints on record against the manager at IDFPR" + }, + { + "nickname": "assoc_sos_id", + "label": "SOS Entity File Number", + "type": "text", + "group": "Legal and Compliance", + "default": "", + "required": false, + "help": "Illinois Secretary of State entity file number — VS-08" + }, + { + "nickname": "assoc_sos_standing", + "label": "Corporate Standing", + "type": "select", + "group": "Legal and Compliance", + "default": "UNK", + "options": ["Active", "Dissolved", "UNK"], + "required": false, + "help": "VS-08" + }, + { + "nickname": "assoc_declaration_recorded", + "label": "Declaration Recorded", + "type": "select", + "group": "Legal and Compliance", + "default": "UNK", + "options": ["Yes", "No", "UNK"], + "required": false, + "help": "VS-10" + }, + { + "nickname": "assoc_declaration_instrument", + "label": "Declaration Instrument No.", + "type": "text", + "group": "Legal and Compliance", + "default": "", + "required": false, + "help": "County Recorder document number — VS-10" + }, + { + "nickname": "assoc_liens_active", + "label": "Active Liens", + "type": "text", + "group": "Legal and Compliance", + "default": "", + "required": false, + "help": "Count or description of active liens recorded against units or the association" + }, + { + "nickname": "assoc_litigation_active", + "label": "Active Litigation", + "type": "text", + "group": "Legal and Compliance", + "default": "", + "required": false, + "help": "Known active cases from public court record" + }, + { + "nickname": "assoc_registered", + "label": "Registered Date", + "type": "readonly", + "group": "Record", + "default": "", + "required": false, + "help": "ISO date when this association was added to the registry — set automatically" + }, + { + "nickname": "assoc_updated", + "label": "Last Updated", + "type": "readonly", + "group": "Record", + "default": "", + "required": false, + "help": "ISO date of most recent update — set automatically" + } + ] +} diff --git a/hubzilla/addon/assoc_profile/assoc_profile.php b/hubzilla/addon/assoc_profile/assoc_profile.php index 175d698..7784179 100644 --- a/hubzilla/addon/assoc_profile/assoc_profile.php +++ b/hubzilla/addon/assoc_profile/assoc_profile.php @@ -3,147 +3,356 @@ /** * Name: Association Profile * Description: Structured diagnostic identity fields for HOA association channels. - * Version: 0.1.0 + * Version: 0.2.0 * MinVersion: 11.0 * MaxVersion: 12.0 */ function assoc_profile_module() {} +require_once 'addon/assoc_profile/assoc_profile_manage.php'; + function assoc_profile_load() { - register_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook'); register_hook('profile_advanced', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_view_hook'); + register_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook'); } function assoc_profile_unload() { - unregister_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook'); unregister_hook('profile_advanced', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_view_hook'); + unregister_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook'); } // ---------------------------------------------------------------------------- // HELPERS // ---------------------------------------------------------------------------- -function assoc_h($value) { - return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +function assoc_h($v) { + return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); } -function assoc_slug_from_address($channel_address) { - // channel_address is "slug@node" — extract the slug - $parts = explode('@', $channel_address); - return $parts[0] ?? ''; +function assoc_slug_from_address($addr) { + return explode('@', $addr)[0] ?? ''; +} + +function assoc_is_operator() { + if (!local_channel()) return false; + $channel = App::get_channel(); + return local_channel() === intval($channel['channel_id']); } // ---------------------------------------------------------------------------- -// REGISTRY I/O +// FILE I/O // ---------------------------------------------------------------------------- function assoc_load_config() { - $path = 'addon/assoc_profile/config.json'; - $raw = @file_get_contents($path); - if ($raw === false) return []; - $data = json_decode($raw, true); - return (json_last_error() === JSON_ERROR_NONE) ? $data : []; + $raw = @file_get_contents('addon/assoc_profile/config.json'); + if (!$raw) return []; + $d = json_decode($raw, true); + return json_last_error() === JSON_ERROR_NONE ? $d : []; +} + +function assoc_registry_path() { + $c = assoc_load_config(); + return $c['registry_file'] ?? 'addon/assoc_profile/assoc_registry.json'; +} + +function assoc_fields_path() { + $c = assoc_load_config(); + return $c['fields_file'] ?? 'addon/assoc_profile/assoc_fields.json'; } function assoc_load_registry() { - $config = assoc_load_config(); - $path = $config['registry_file'] - ?? 'addon/assoc_profile/assoc_registry.json'; - $raw = @file_get_contents($path); - if ($raw === false) { - logger('assoc_profile: registry file not found: ' . $path); - return []; - } - $data = json_decode($raw, true); - if (json_last_error() !== JSON_ERROR_NONE) { - logger('assoc_profile: registry file malformed: ' . $path); - return []; - } - // Strip meta keys - foreach (['_note','_version','_slug_format','_select_values'] as $k) { - unset($data[$k]); - } - return $data; + $raw = @file_get_contents(assoc_registry_path()); + if (!$raw) { logger('assoc_profile: registry not found'); return []; } + $d = json_decode($raw, true); + if (json_last_error() !== JSON_ERROR_NONE) { logger('assoc_profile: registry malformed'); return []; } + foreach (['_note','_version','_slug_format','_select_values'] as $k) unset($d[$k]); + return $d; } function assoc_write_registry($data) { - $config = assoc_load_config(); - $path = $config['registry_file'] - ?? 'addon/assoc_profile/assoc_registry.json'; - $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - if ($json === false) { - logger('assoc_profile: failed to encode registry'); - return false; - } - $result = file_put_contents($path, $json, LOCK_EX); - if ($result === false) { - logger('assoc_profile: failed to write registry: ' . $path); - return false; + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + if (!$json) { logger('assoc_profile: encode failed'); return false; } + if (file_put_contents(assoc_registry_path(), $json, LOCK_EX) === false) { + logger('assoc_profile: write failed'); return false; } return true; } +function assoc_load_fields() { + $raw = @file_get_contents(assoc_fields_path()); + if (!$raw) { logger('assoc_profile: fields file not found'); return ['groups'=>[],'fields'=>[]]; } + $d = json_decode($raw, true); + if (json_last_error() !== JSON_ERROR_NONE) { logger('assoc_profile: fields file malformed'); return ['groups'=>[],'fields'=>[]]; } + return $d; +} + +function assoc_write_fields($data) { + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + if (!$json) return false; + return file_put_contents(assoc_fields_path(), $json, LOCK_EX) !== false; +} + function assoc_get($slug) { + $r = assoc_load_registry(); + return $r[$slug] ?? null; +} + +// ---------------------------------------------------------------------------- +// FIELD HELPERS +// ---------------------------------------------------------------------------- + +function assoc_field_map() { + $def = assoc_load_fields(); + $map = []; + foreach ($def['fields'] ?? [] as $f) { + $map[$f['nickname']] = $f; + } + return $map; +} + +function assoc_blank_entry() { + $map = assoc_field_map(); + $entry = []; + foreach ($map as $nick => $f) { + $entry[$nick] = $f['default'] ?? ''; + } + return $entry; +} + +function assoc_backfill_all($new_field_def) { $registry = assoc_load_registry(); - return $registry[$slug] ?? null; + $nick = $new_field_def['nickname']; + $default = $new_field_def['default'] ?? ''; + foreach ($registry as $slug => &$entry) { + if (!isset($entry[$nick])) { + $entry[$nick] = $default; + } + } + unset($entry); + return $registry; +} + +function assoc_remove_field_from_all($nickname) { + $registry = assoc_load_registry(); + foreach ($registry as $slug => &$entry) { + unset($entry[$nickname]); + } + unset($entry); + return $registry; } // ---------------------------------------------------------------------------- -// VALIDATION +// CONTENT ROUTER // ---------------------------------------------------------------------------- -function assoc_allowed_values() { - return [ - 'assoc_type' => ['Condominium','Master','CIC','Unincorporated','UNK','Disputed'], - 'assoc_mgmt_certified' => ['Yes','No','UNK'], - 'assoc_sos_standing' => ['Active','Dissolved','UNK'], - 'assoc_declaration_recorded' => ['Yes','No','UNK'], - ]; -} - -function assoc_validate_select($field, $value) { - $allowed = assoc_allowed_values(); - if (!isset($allowed[$field])) return true; - return in_array($value, $allowed[$field], true); -} - -function assoc_field_names() { - return [ - 'assoc_legal_name', 'assoc_type', 'assoc_statute', - 'assoc_street', 'assoc_city', 'assoc_county', 'assoc_state', - 'assoc_zip', 'assoc_placekey', 'assoc_website', - 'assoc_buildings', 'assoc_units', 'assoc_year_built', 'assoc_stories', - 'assoc_mgmt_company', 'assoc_mgmt_contact', 'assoc_mgmt_certified', - 'assoc_cai_listing', - 'assoc_sos_id', 'assoc_sos_standing', - 'assoc_declaration_recorded', 'assoc_declaration_instrument', - 'assoc_registered', 'assoc_updated', - ]; -} - -// ---------------------------------------------------------------------------- -// PROFILE EDIT HOOK -// ---------------------------------------------------------------------------- - -function assoc_profile_edit_hook(&$b) { - if (!local_channel()) return; - - $channel = App::get_channel(); - $slug = assoc_slug_from_address($channel['channel_address'] ?? ''); - if (!$slug) return; - - $entry = assoc_get($slug); - if (!$entry) return; - - // Only channel owner may edit - if (local_channel() !== intval($channel['channel_id'])) return; - +function assoc_profile_content() { if (function_exists('head_add_css')) { head_add_css('/addon/assoc_profile/view/css/assoc_profile.css'); } - $b['html'] .= assoc_render_edit_form($slug, $entry); + $action = argv(1) ?? ''; + + if ($action !== 'manage') { + return ''; + } + + if (!assoc_is_operator()) { + return '
No associations registered.
'; + } else { + $out .= '| Slug | Legal Name | Type | Units | Updated | |
|---|---|---|---|---|---|
' . assoc_h($slug) . ' | ';
+ $out .= '' . assoc_h($entry['assoc_legal_name'] ?? '—') . ' | '; + $out .= '' . assoc_h($entry['assoc_type'] ?? '—') . ' | '; + $out .= '' . assoc_h($entry['assoc_units'] ?? '—') . ' | '; + $out .= '' . assoc_h($entry['assoc_updated'] ?? '—') . ' | '; + $out .= 'Edit | '; + $out .= '
' . assoc_h($slug) . '
Changes to labels and groups take effect immediately. Adding a field backfills all existing associations with the default value. Removing a field deletes its data from all associations — this cannot be undone.
'; + + $out .= ''; + + foreach ($fields as $f) { + if (($f['type'] ?? '') === 'readonly') continue; + $nick = $f['nickname']; + $out .= ''; + } + + $out .= '