diff --git a/application/controllers/Notes.php b/application/controllers/Notes.php index ba27c2a00..991b2e591 100644 --- a/application/controllers/Notes.php +++ b/application/controllers/Notes.php @@ -1,119 +1,320 @@ load->model('user_model'); + if (!$this->user_model->authorize(2)) { + $this->session->set_flashdata('error', __("You're not allowed to do that!")); + redirect('dashboard'); + exit; + } + } - function __construct() - { - parent::__construct(); + // Main notes page: lists notes and categories + public function index() { + $this->load->model('note'); + $data = []; + // Get all notes for logged-in user + $data['notes'] = $this->note->list_all(); + // Get possible categories + $data['categories'] = Note::get_possible_categories(); + // Get note counts per category + $category_counts = []; + foreach (Note::get_possible_category_keys() as $category) { + $category_counts[$category] = $this->note->count_by_category($category); + } + $data['category_counts'] = $category_counts; + // Get total notes count + $data['all_notes_count'] = $this->note->count_by_category(); + $data['page_title'] = __("Notes"); + // Render views + $this->load->view('interface_assets/header', $data); + $this->load->view('notes/main'); + $this->load->view('interface_assets/footer'); + } - $this->load->model('user_model'); - if(!$this->user_model->authorize(2)) { $this->session->set_flashdata('error', __("You're not allowed to do that!")); redirect('dashboard'); } - } + // Add a new note + function add() { + $this->load->model('note'); + $this->load->library('form_validation'); + $this->load->library('callbook'); // Used for callsign parsing + $suggested_title = null; + // Validate form fields + $this->form_validation->set_rules('title', 'Note Title', 'required|callback_contacts_title_unique'); // Custom callback for Contacts category + $this->form_validation->set_rules('content', 'Content', 'required'); + if ($this->form_validation->run() == FALSE) { + $category = $this->input->post('category', TRUE); + if ($category === 'Contacts') { - /* Displays all notes in a list */ - public function index() { + $suggested_title = strtoupper($this->callbook->get_plaincall($this->input->post('title', TRUE))); + } + $data['suggested_title'] = $suggested_title; + $data['page_title'] = __("Add Notes"); + $this->load->view('interface_assets/header', $data); + $this->load->view('notes/add'); + $this->load->view('interface_assets/footer'); + } else { + $category = $this->input->post('category', TRUE); + $title = $this->input->post('title', TRUE); + $content = $this->input->post('content', TRUE); + $local_time = $this->input->post('local_time', TRUE); + if ($category === 'Contacts') { + $title = strtoupper($this->callbook->get_plaincall($title)); + } + $this->note->add($category, $title, $content, $local_time); + redirect('notes'); + } + } + + // View a single note + function view($id = null) { + $this->load->model('note'); + $clean_id = $this->security->xss_clean($id); + // Validate note ID and ownership + if (!is_numeric($clean_id) || !$this->note->belongs_to_user($clean_id, $this->session->userdata('user_id'))) { + show_404(); + } + $data['note'] = $this->note->view($clean_id); + $data['page_title'] = __("Note"); + // Render note view + $this->load->view('interface_assets/header', $data); + $this->load->view('notes/view'); + $this->load->view('interface_assets/footer'); + } + + // Edit a note + function edit($id = null) { + $this->load->model('note'); + $clean_id = $this->security->xss_clean($id); + // Validate note ID and ownership + if (!is_numeric($clean_id) || !$this->note->belongs_to_user($clean_id, $this->session->userdata('user_id'))) { + show_404(); + } + $this->load->library('callbook'); // Used for callsign parsing + $data['id'] = $clean_id; + $data['note'] = $this->note->view($clean_id); + $this->load->library('form_validation'); + $suggested_title = null; + // Validate form fields + $this->form_validation->set_rules('title', 'Note Title', 'required|callback_contacts_title_unique_edit'); // Custom callback for Contacts category + $this->form_validation->set_rules('content', 'Content', 'required'); + if ($this->form_validation->run() == FALSE) { + $category = $this->input->post('category', TRUE); + if ($category === 'Contacts') { + $suggested_title = strtoupper($this->callbook->get_plaincall($this->input->post('title', TRUE))); + } + $data['suggested_title'] = $suggested_title; + $data['page_title'] = __("Edit Note"); + $this->load->view('interface_assets/header', $data); + $this->load->view('notes/edit'); + $this->load->view('interface_assets/footer'); + } else { + $category = $this->input->post('category', TRUE); + $title = $this->input->post('title', TRUE); + $content = $this->input->post('content', TRUE); + $local_time = $this->input->post('local_time', TRUE); + $note_id = $this->input->post('id', TRUE); + if ($category === 'Contacts') { + $title = strtoupper($this->callbook->get_plaincall($title)); + } + $this->note->edit($note_id, $category, $title, $content, $local_time); + redirect('notes'); + } + } + + // API search for notes + function search() { + $this->load->model('note'); + // Map and sanitize search parameters + $searchCriteria = $this->mapParameters(); + // Get pagination and sorting parameters + $page = (int)$this->input->post('page', TRUE); + $per_page = (int)$this->input->post('per_page', TRUE); + $sort_col = $this->input->post('sort_col', TRUE); + $sort_dir = $this->input->post('sort_dir', TRUE); + if ($per_page < 1) $per_page = 15; + if ($page < 1) $page = 1; + // Get paginated, sorted notes + $result = $this->note->search_paginated($searchCriteria, $page, $per_page, $sort_col, $sort_dir); + $response = [ + 'notes' => $result['notes'], + 'total' => $result['total'] + ]; + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + // Map and sanitize search parameters + function mapParameters() { + return array( + 'cat' => $this->input->post('cat', TRUE), + 'search' => $this->input->post('search', TRUE) + ); + } + + // Delete a note + function delete($id = null) { $this->load->model('note'); - $data['notes'] = $this->note->list_all(); - $data['page_title'] = __("Notes"); - $this->load->view('interface_assets/header', $data); - $this->load->view('notes/main'); - $this->load->view('interface_assets/footer'); - } - - /* Provides function for adding notes to the system. */ - function add() { - - $this->load->model('note'); - - $this->load->library('form_validation'); - $this->form_validation->set_rules('title', 'Note Title', 'required'); - $this->form_validation->set_rules('content', 'Content', 'required'); + $clean_id = $this->security->xss_clean($id); + // Validate note ID and ownership + if (!is_numeric($clean_id) || !$this->note->belongs_to_user($clean_id, $this->session->userdata('user_id'))) { + show_404(); + } + $this->note->delete($clean_id); + redirect('notes'); + } + // Duplicate a note by ID for the logged-in user, appending #[timestamp] to title + public function duplicate($id = null) { + $this->load->model('note'); + $clean_id = $this->security->xss_clean($id); + if (!is_numeric($clean_id) || !$this->note->belongs_to_user($clean_id, $this->session->userdata('user_id'))) { + show_404(); + } + $timestamp = $this->input->post('timestamp', TRUE); + $user_id = $this->session->userdata('user_id'); + // Get original note + $query = $this->db->get_where('notes', array('id' => $clean_id, 'user_id' => $user_id)); + if ($query->num_rows() !== 1) { + show_404(); + } + $note = $query->row(); + // Simple duplicate check + $category = $note->cat; + $new_title = $note->title . ' #' . $timestamp; + $check_title = $new_title; + if ($category === 'Contacts') { + $check_title = strtoupper($new_title); + } + $existing = $this->db->get_where('notes', [ + 'cat' => $category, + 'user_id' => $user_id, + 'title' => $check_title + ])->num_rows(); + if ($existing > 0) { + $this->output->set_content_type('application/json')->set_output(json_encode(['status' => 'error', 'message' => 'Duplicate note title for this category and user - not allowed for Contacts category.'])); + return; + } + // Duplicate note with new title + $data = array( + 'cat' => $note->cat, + 'title' => $new_title, + 'note' => $note->note, + 'user_id' => $user_id, + 'creation_date' => date('Y-m-d H:i:s'), + 'last_modified' => date('Y-m-d H:i:s') + ); + $this->db->insert('notes', $data); + $this->output->set_content_type('application/json')->set_output(json_encode(['status' => 'ok'])); + } - if ($this->form_validation->run() == FALSE) - { - $data['page_title'] = __("Add Notes"); - $this->load->view('interface_assets/header', $data); - $this->load->view('notes/add'); - $this->load->view('interface_assets/footer'); - } - else - { - $this->note->add(); - - redirect('notes'); - } - } - - /* View Notes */ - function view($id) { + // API endpoint to get note counts per category and total + public function get_category_counts() { + $this->load->model('note'); + $categories = Note::get_possible_category_keys(); + $category_counts = []; + foreach ($categories as $category) { + $category_counts[$category] = $this->note->count_by_category($category); + } + $all_notes_count = $this->note->count_by_category(); + $response = [ + 'category_counts' => $category_counts, + 'all_notes_count' => $all_notes_count + ]; + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } - $clean_id = $this->security->xss_clean($id); + // API endpoint to check for duplicate note title in category for user + public function check_duplicate() { + $user_id = $this->session->userdata('user_id'); + $category = $this->input->get('category', TRUE); + $title = $this->input->get('title', TRUE); + $id = $this->input->get('id', TRUE); // Optional, for edit + $check_title = $title; + if ($category === 'Contacts') { + $check_title = strtoupper($title); + } + $where = [ + 'category' => $category, + 'user_id' => $user_id, + 'title' => $check_title + ]; + $query = $this->db->get_where('notes', $where); + $duplicate = false; + if ($id) { + foreach ($query->result() as $note) { + if ($note->id != $id) { + $duplicate = true; + break; + } + } + } else { + $duplicate = $query->num_rows() > 0; + } + $this->output->set_content_type('application/json')->set_output(json_encode(['duplicate' => $duplicate])); + } - if (! is_numeric($clean_id)) { - show_404(); - } + // Form validation callback for add: unique Contacts note title for user, only core callsign + public function contacts_title_unique($title = null) { + $category = $this->input->post('category', TRUE); + if ($category === 'Contacts') { + $this->load->library('callbook'); // Used for callsign parsing + $user_id = $this->session->userdata('user_id'); + $core = strtoupper($this->callbook->get_plaincall($title)); + // Only fail if prefix or suffix is present + if (strtoupper($title) <> $core) { + $this->form_validation->set_message('contacts_title_unique', sprintf(__("Contacts note title must be a callsign only, without prefix/suffix. Suggested: %s"), $core)); + return FALSE; + } + $existing = $this->db->get_where('notes', [ + 'cat' => 'Contacts', + 'user_id' => $user_id, + 'title' => $core + ])->num_rows(); + if ($existing > 0) { + $this->form_validation->set_message('contacts_title_unique', __("A note with this callsign already exists in your Contacts. Please enter a unique callsign.")); + return FALSE; + } + return TRUE; + } + return TRUE; + } + // Form validation callback for edit: unique Contacts note title for user (ignore current note), only core callsign + public function contacts_title_unique_edit($title = null) { + $category = $this->input->post('category', TRUE); + if ($category === 'Contacts') { + $this->load->library('callbook'); // Used for callsign parsing + $user_id = $this->session->userdata('user_id'); + $note_id = $this->input->post('id', TRUE); + $core = strtoupper($this->callbook->get_plaincall($title)); + // Only fail if prefix or suffix is present + if (strtoupper($title) <> $core) { + $this->form_validation->set_message('contacts_title_unique_edit', sprintf(__("Contacts note title must be a callsign only, without prefix/suffix. Suggested: %s"),$core)); + return FALSE; + } + $query = $this->db->get_where('notes', [ + 'cat' => 'Contacts', + 'user_id' => $user_id, + 'title' => $core + ]); + foreach ($query->result() as $note) { + if ($note->id != $note_id) { + $this->form_validation->set_message('contacts_title_unique_edit', __("A note with this callsign already exists in your Contacts. Please enter a unique callsign.")); + return FALSE; + } + } + return TRUE; + } + return TRUE; + } - $this->load->model('note'); - - $data['note'] = $this->note->view($clean_id); - - // Display - $data['page_title'] = __("Note"); - $this->load->view('interface_assets/header', $data); - $this->load->view('notes/view'); - $this->load->view('interface_assets/footer'); - } - - /* Edit Notes */ - function edit($id) { - - $clean_id = $this->security->xss_clean($id); - - if (! is_numeric($clean_id)) { - show_404(); - } - - $this->load->model('note'); - $data['id'] = $clean_id; - - $data['note'] = $this->note->view($clean_id); - - $this->load->library('form_validation'); - - $this->form_validation->set_rules('title', 'Note Title', 'required'); - $this->form_validation->set_rules('content', 'Content', 'required'); - - - if ($this->form_validation->run() == FALSE) - { - $data['page_title'] = __("Edit Note"); - $this->load->view('interface_assets/header', $data); - $this->load->view('notes/edit'); - $this->load->view('interface_assets/footer'); - } - else - { - $this->note->edit(); - - redirect('notes'); - } - } - - /* Delete Note */ - function delete($id) { - - $clean_id = $this->security->xss_clean($id); - - if (! is_numeric($clean_id)) { - show_404(); - } - - $this->load->model('note'); - $this->note->delete($clean_id); - - redirect('notes'); - } -} \ No newline at end of file +} diff --git a/application/models/Note.php b/application/models/Note.php index 8027ab0d5..4939ba561 100644 --- a/application/models/Note.php +++ b/application/models/Note.php @@ -1,9 +1,25 @@ __("Contacts"), // QSO partner notes + 'General' => __("General"), // General notes + 'Antennas' => __("Antennas"), // Antenna-related notes + 'Satellites' => __("Satellites") // Satellite-related notes + ]; + } + // Get list of possible note category keys (for backwards compatibility) + public static function get_possible_category_keys() { + return array_keys(self::get_possible_categories()); + } + + // List all notes for a user or API key function list_all($api_key = null) { - if ($api_key == null) { + // Determine user ID + if ($api_key == null) { $user_id = $this->session->userdata('user_id'); } else { $this->load->model('api_model'); @@ -12,64 +28,205 @@ class Note extends CI_Model { $user_id = $this->api_model->key_userid($api_key); } } - - $this->db->where('user_id', $user_id); - return $this->db->get('notes'); + $sql = "SELECT * FROM notes WHERE user_id = ?"; + return $this->db->query($sql, array($user_id)); } - function add() { - $data = array( - 'cat' => $this->input->post('category', TRUE), - 'title' => $this->input->post('title', TRUE), - 'note' => $this->input->post('content', TRUE), - 'user_id' => $this->session->userdata('user_id') - ); - - $this->db->insert('notes', $data); + // Add a new note for the logged-in user + function add($category, $title, $content, $local_time = null) { + $user_id = $this->session->userdata('user_id'); + $check_title = $title; + if ($category === 'Contacts') { + $check_title = strtoupper($title); + } + $sql = "SELECT COUNT(*) as count FROM notes WHERE cat = ? AND user_id = ? AND title = ?"; + $check_result = $this->db->query($sql, array($category, $user_id, $check_title)); + if ($check_result->row()->count > 0 && $category === 'Contacts') { + show_error(__("In Contacts category, the titles of the notes need to be unique.")); + return; + } + $creation_date_utc = gmdate('Y-m-d H:i:s'); + if ($local_time) { + $dt = new DateTime($local_time, new DateTimeZone(date_default_timezone_get())); + $dt->setTimezone(new DateTimeZone('UTC')); + $creation_date_utc = $dt->format('Y-m-d H:i:s'); + } + $sql = "INSERT INTO notes (cat, title, note, user_id, creation_date, last_modified) VALUES (?, ?, ?, ?, ?, ?)"; + $this->db->query($sql, array($category, $title, $content, $user_id, $creation_date_utc, $creation_date_utc)); } - function edit() { - $data = array( - 'cat' => $this->input->post('category', TRUE), - 'title' => $this->input->post('title', TRUE), - 'note' => $this->input->post('content', TRUE) - ); - - $this->db->where('id', $this->input->post('id', TRUE)); - $this->db->where('user_id', $this->session->userdata('user_id')); - $this->db->update('notes', $data); + // Edit an existing note for the logged-in user + function edit($note_id, $category, $title, $content, $local_time = null) { + $user_id = $this->session->userdata('user_id'); + $check_title = $title; + if ($category === 'Contacts') { + $check_title = strtoupper($title); + } + $check_sql = "SELECT id FROM notes WHERE cat = ? AND user_id = ? AND title = ?"; + $check_result = $this->db->query($check_sql, array($category, $user_id, $check_title)); + foreach ($check_result->result() as $note) { + if ($note->id != $note_id && $category === 'Contacts') { + show_error(__("In Contacts category, the titles of the notes need to be unique.")); + return; + } + } + $last_modified_utc = gmdate('Y-m-d H:i:s'); + if ($local_time) { + $dt = new DateTime($local_time, new DateTimeZone(date_default_timezone_get())); + $dt->setTimezone(new DateTimeZone('UTC')); + $last_modified_utc = $dt->format('Y-m-d H:i:s'); + } + $sql = "UPDATE notes SET cat = ?, title = ?, note = ?, last_modified = ? WHERE id = ? AND user_id = ?"; + $this->db->query($sql, array($category, $title, $content, $last_modified_utc, $note_id, $user_id)); } + // Delete a note by ID for the logged-in user function delete($id) { - $clean_id = $this->security->xss_clean($id); - - if (! is_numeric($clean_id)) { + if (!is_numeric($clean_id)) { show_404(); } - - $this->db->delete('notes', array('id' => $clean_id, 'user_id' => $this->session->userdata('user_id'))); + $sql = "DELETE FROM notes WHERE id = ? AND user_id = ?"; + $this->db->query($sql, array($clean_id, $this->session->userdata('user_id'))); } + // View a note by ID for the logged-in user function view($id) { - $clean_id = $this->security->xss_clean($id); - - if (! is_numeric($clean_id)) { + if (!is_numeric($clean_id)) { show_404(); } - - // Get Note - $this->db->where('id', $clean_id); - $this->db->where('user_id', $this->session->userdata('user_id')); - return $this->db->get('notes'); + $sql = "SELECT * FROM notes WHERE id = ? AND user_id = ?"; + return $this->db->query($sql, array($clean_id, $this->session->userdata('user_id'))); } + // Check if note belongs to a user + public function belongs_to_user($note_id, $user_id) { + $sql = "SELECT COUNT(*) as count FROM notes WHERE id = ? AND user_id = ?"; + $query = $this->db->query($sql, array($note_id, $user_id)); + return $query->row()->count > 0; + } + + // Search notes by category and/or text for the logged-in user + public function search($criteria = []) { + $user_id = $this->session->userdata('user_id'); + $params = array($user_id); + $sql = "SELECT * FROM notes WHERE user_id = ?"; + + // Filter by category + if (!empty($criteria['cat'])) { + $cats = array_map('trim', explode(',', $criteria['cat'])); + if (count($cats) > 0) { + $placeholders = str_repeat('?,', count($cats) - 1) . '?'; + $sql .= " AND cat IN ($placeholders)"; + $params = array_merge($params, $cats); + } + } + + // Filter by search term (title or note) + if (!empty($criteria['search'])) { + $search = '%' . $criteria['search'] . '%'; + $sql .= " AND (title LIKE ? OR note LIKE ?)"; + $params[] = $search; + $params[] = $search; + } + + $query = $this->db->query($sql, $params); + return $query->result(); + } + + // Count notes by category for the logged-in user + public function count_by_category($category = null) { + $user_id = $this->session->userdata('user_id'); + $params = array($user_id); + $sql = "SELECT COUNT(*) as count FROM notes WHERE user_id = ?"; + + if ($category !== null) { + $sql .= " AND cat = ?"; + $params[] = $category; + } + + $query = $this->db->query($sql, $params); + return $query->row()->count; + } + + // Get categories with their respective note counts for the logged-in user + public function get_categories_with_counts() { + $user_id = $this->session->userdata('user_id'); + $sql = "SELECT cat, COUNT(*) as count FROM notes WHERE user_id = ? GROUP BY cat"; + $query = $this->db->query($sql, array($user_id)); + $result = []; + foreach ($query->result() as $row) { + $result[$row->cat] = (int)$row->count; + } + return $result; + } + + // Count all notes with user_id NULL (system notes) function CountAllNotes() { - // count all notes - $this->db->where('user_id =', NULL); - $query = $this->db->get('notes'); - return $query->num_rows(); + $sql = "SELECT COUNT(*) as count FROM notes WHERE user_id IS NULL"; + $query = $this->db->query($sql); + return $query->row()->count; + } + + // Search notes with pagination and sorting for the logged-in user + public function search_paginated($criteria = [], $page = 1, $per_page = 25, $sort_col = null, $sort_dir = null) { + $user_id = $this->session->userdata('user_id'); + $params = array($user_id); + $where_clause = "WHERE user_id = ?"; + + // Filter by category + if (!empty($criteria['cat'])) { + $cats = array_map('trim', explode(',', $criteria['cat'])); + if (count($cats) > 0) { + $placeholders = str_repeat('?,', count($cats) - 1) . '?'; + $where_clause .= " AND cat IN ($placeholders)"; + $params = array_merge($params, $cats); + } + } + + // Filter by search term (title or note) + if (!empty($criteria['search'])) { + $search = '%' . $criteria['search'] . '%'; + $where_clause .= " AND (title LIKE ? OR note LIKE ?)"; + $params[] = $search; + $params[] = $search; + } + + // Get total count + $count_sql = "SELECT COUNT(*) as count FROM notes $where_clause"; + $count_query = $this->db->query($count_sql, $params); + $total = $count_query->row()->count; + + // Build main query with sorting + $sql = "SELECT id, cat, title, note, creation_date, last_modified FROM notes $where_clause"; + + // Sorting + $columns = ['cat', 'title', 'creation_date', 'last_modified']; + if ($sort_col !== null && in_array($sort_col, $columns) && ($sort_dir === 'asc' || $sort_dir === 'desc')) { + $sql .= " ORDER BY $sort_col $sort_dir"; + } + + // Pagination + $offset = ($page - 1) * $per_page; + $sql .= " LIMIT $per_page OFFSET $offset"; + + $query = $this->db->query($sql, $params); + $notes = []; + foreach ($query->result() as $row) { + $notes[] = [ + 'id' => $row->id, + 'cat' => $row->cat, + 'title' => $row->title, + 'note' => $row->note, + 'creation_date' => $row->creation_date, + 'last_modified' => $row->last_modified + ]; + } + return [ + 'notes' => $notes, + 'total' => $total + ]; } } diff --git a/application/views/interface_assets/footer.php b/application/views/interface_assets/footer.php index 29d064690..a98bc81d4 100644 --- a/application/views/interface_assets/footer.php +++ b/application/views/interface_assets/footer.php @@ -56,6 +56,19 @@ var lang_general_word_please_wait = ""; var lang_general_states_deprecated = ""; var lang_gen_hamradio_sat_info = ""; + var lang_notes_error_loading = ""; + var lang_notes_sort = ""; + var lang_notes_duplication_disabled = ""; + var lang_notes_duplicate = ""; + var lang_general_word_delete = ""; + var lang_general_word_duplicate = ""; + var lang_notes_delete = ""; + var lang_notes_duplicate = ""; + var lang_notes_delete_confirmation = ""; + var lang_notes_duplicate_confirmation = ""; + var lang_notes_duplication_disabled_short = ""; + var lang_notes_not_found = ""; + @@ -278,9 +291,11 @@ function stopImpersonate_modal() { -uri->segment(1) == "notes" && ($this->uri->segment(2) == "add" || $this->uri->segment(2) == "edit") ) { ?> +uri->segment(1) == "notes" ) { ?> + uri->segment(2) == "add" || $this->uri->segment(2) == "edit") { ?> + diff --git a/application/views/notes/add.php b/application/views/notes/add.php index 767b838dd..23df9cf51 100644 --- a/application/views/notes/add.php +++ b/application/views/notes/add.php @@ -1,52 +1,61 @@ - +
- -
-
-

- -
- -
- - -
- x -
    ', '')); ?>
+
+
+

+ +
+
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
- - -
- -
- - -
- -
- - -
- -
- - -
- - -
- -
diff --git a/application/views/notes/edit.php b/application/views/notes/edit.php index 1169bcf66..18ad936d4 100644 --- a/application/views/notes/edit.php +++ b/application/views/notes/edit.php @@ -1,57 +1,72 @@ - - +
result() as $row) { ?> -
-
-

- -
- -
- - -
- x -
    ', '')); ?>
+
+
+

+ +
+
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+
+
- - -
- -
- - -
- -
- - -
- -
- - -
- - - -
- - -
- +
diff --git a/application/views/notes/main.php b/application/views/notes/main.php index 93196ae64..95215855c 100644 --- a/application/views/notes/main.php +++ b/application/views/notes/main.php @@ -1,38 +1,84 @@ +
-
-
-

- -
- -
- - num_rows() > 0) - { - echo "

".__("Your Notes")."

"; - echo "
    "; - foreach ($notes->result() as $row) - { - echo "
  • "; - echo "id."\">".$row->title.""; - echo "
  • "; - } - echo "
"; - } else { - echo "

".__("You don't currently have any notes, these are a fantastic way of storing data like ATU settings, beacons and general station notes and its better than paper as you can't lose them!")."

"; - } - - ?> -
+
+

+ +
+
+
+
+ +
+ + $value) { + $decoded_categories[$key] = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + ?> + $category_label): ?> + + + + + +
+
+
+ +
+ "> + +
+
+
+
+ + + + + + + + + + + + + + + +
+
+
diff --git a/application/views/notes/view.php b/application/views/notes/view.php index c38c1c503..86b05ce4d 100644 --- a/application/views/notes/view.php +++ b/application/views/notes/view.php @@ -1,26 +1,55 @@ +
+
+ result() as $row) { ?> +
+

+ +
+
+
+
+
+ + : cat); ?> + cat == 'Contacts'): ?> + "> + + + + +
+

title); ?>

+
+
+ +
+ +
+
+
+ + -
- result() as $row) { ?> -
-

- title; ?>

- -
-
- - - - - - -
- -
\ No newline at end of file +
+
+
+ +
+
diff --git a/assets/js/sections/notes.js b/assets/js/sections/notes.js index d8884d955..bfc5c5f22 100644 --- a/assets/js/sections/notes.js +++ b/assets/js/sections/notes.js @@ -1,68 +1,560 @@ -const notes = new EasyMDE({element: document.getElementById('notes'), forceSync: true, spellChecker: false, placeholder: 'Create note...', maxHeight: '350px', - toolbar: [ - { - name: "bold", - action: EasyMDE.toggleBold, - className: "fa fa-bold", - title: "bold", - }, - { - name: "italic", - action: EasyMDE.toggleItalic, - className: "fa fa-italic", - title: "italic", - }, - { - name: "heading", - action: EasyMDE.toggleHeadingSmaller, - className: "fa fa-header", - title: "heading", - }, - "|", - { - name: "quote", - action: EasyMDE.toggleBlockquote, - className: "fa fa-quote-left", - title: "quote", - }, - { - name: "unordered-list", - action: EasyMDE.toggleUnorderedList, - className: "fa fa-list-ul", - title: "unordered list", - }, - { - name: "ordered-list", - action: EasyMDE.toggleOrderedList, - className: "fa fa-list-ol", - title: "ordered list", - }, - "|", - { - name: "link", - action: EasyMDE.drawLink, - className: "fa fa-link", - title: "link" - }, - { - name: "image", - action: EasyMDE.drawImage, - className: "fa fa-image", - title: "image" - }, - "|", - { - name: "preview", - action: EasyMDE.togglePreview, - className: "fa fa-eye no-disable", - title: "preview" - }, - "|", - { - name: "guide", - action: "https://www.markdownguide.org/basic-syntax/", // link - className: "fa fa-question-circle", - title: "Markdown Guide" +// Initialize EasyMDE for the notes textarea if on add/edit page +if (typeof EasyMDE !== 'undefined') { + const notes = new EasyMDE({ + element: document.getElementById('notes'), + forceSync: true, + spellChecker: false, + placeholder: 'Create note...', + maxHeight: '350px', + toolbar: [ + { + name: "bold", + action: EasyMDE.toggleBold, + className: "fa fa-bold", + title: "bold", + }, + { + name: "italic", + action: EasyMDE.toggleItalic, + className: "fa fa-italic", + title: "italic", + }, + { + name: "heading", + action: EasyMDE.toggleHeadingSmaller, + className: "fa fa-header", + title: "heading", + }, + "|", + { + name: "quote", + action: EasyMDE.toggleBlockquote, + className: "fa fa-quote-left", + title: "quote", + }, + { + name: "unordered-list", + action: EasyMDE.toggleUnorderedList, + className: "fa fa-list-ul", + title: "unordered list", + }, + { + name: "ordered-list", + action: EasyMDE.toggleOrderedList, + className: "fa fa-list-ol", + title: "ordered list", + }, + "|", + { + name: "link", + action: EasyMDE.drawLink, + className: "fa fa-link", + title: "link" + }, + { + name: "image", + action: EasyMDE.drawImage, + className: "fa fa-image", + title: "image" + }, + "|", + { + name: "preview", + action: EasyMDE.togglePreview, + className: "fa fa-eye no-disable", + title: "preview" + }, + "|", + { + name: "guide", + action: "https://www.markdownguide.org/basic-syntax/", // link + className: "fa fa-question-circle", + title: "Markdown Guide" + } + ] + }); + + // Always sync EasyMDE content to textarea before form submit + var notesForm = document.getElementById('notes_add'); + if (notesForm) { + notesForm.addEventListener('submit', function(e) { + if (notes && notes.codemirror) { + notes.codemirror.save(); // forceSync + } + }); + } + // If validation fails and textarea has value, restore it to EasyMDE + var notesTextarea = document.getElementById('notes'); + if (notesTextarea && notesTextarea.value && notes.value() !== notesTextarea.value) { + notes.value(notesTextarea.value); + } +} + +// Main notes page functionality +document.addEventListener('DOMContentLoaded', function() { + // Early exit if we're not on a notes page + var notesTableBody = document.querySelector('#notesTable tbody'); + var isNotesMainPage = notesTableBody !== null; + + var base_url = window.base_url || document.body.getAttribute('data-baseurl') || '/'; + + // Constants + const NOTES_PER_PAGE = 15; + const SEARCH_MIN_LENGTH = 3; + const SORT_COLUMN_MAP = ['cat', 'title', 'creation_date', 'last_modified', null]; + + // Cache frequently used DOM elements to avoid repeated queries + var domCache = { + notesTableBody: notesTableBody, + notesTable: document.getElementById('notesTable'), + categoryButtons: document.querySelectorAll('.category-btn'), + searchBox: document.getElementById('notesSearchBox'), + resetBtn: document.getElementById('notesSearchReset'), + titleInput: document.getElementById('inputTitle'), + catSelect: document.getElementById('catSelect'), + saveBtn: document.querySelector('button[type="submit"]'), + form: document.getElementById('notes_add'), + paginationContainer: document.getElementById('notesPagination') + }; + + // Create pagination container if it doesn't exist + if (!domCache.paginationContainer) { + domCache.paginationContainer = document.createElement('div'); + domCache.paginationContainer.id = 'notesPagination'; + domCache.paginationContainer.className = 'd-flex justify-content-center my-3'; + var notesTableContainer = document.getElementById('notesTableContainer'); + if (notesTableContainer) { + notesTableContainer.appendChild(domCache.paginationContainer); } - ] + } + + // Initialize existing UTC time cells and tooltips on page load + // Helper function to initialize table elements after rendering + function initializeTableElements() { + // Convert UTC times to local time + document.querySelectorAll('#notesTable td[data-utc]').forEach(function(td) { + var utc = td.getAttribute('data-utc'); + td.textContent = utcToLocal(utc); + }); + + // Initialize Bootstrap tooltips for note titles + var tooltipTriggerList = [].slice.call(document.querySelectorAll('#notesTable a[data-bs-toggle="tooltip"]')); + tooltipTriggerList.forEach(function(el) { + new bootstrap.Tooltip(el); + }); + } + + // Run initialization for existing table content + initializeTableElements(); + + // Duplicate Contacts note check for add/edit pages + var modal; + function showModal(msg) { + if (!modal) { + modal = document.createElement('div'); + modal.className = 'modal fade'; + modal.innerHTML = ''; + document.body.appendChild(modal); + } + modal.querySelector('.modal-body p').textContent = msg; + $(modal).modal('show'); + } + + // Reload category counters via AJAX + function reloadCategoryCounters() { + fetch(base_url + 'index.php/notes/get_category_counts', { method: 'POST' }) + .then(response => response.json()) + .then(counts => { + domCache.categoryButtons.forEach(function(btn) { + var cat = btn.getAttribute('data-category'); + var countSpan = btn.querySelector('.badge'); + if (countSpan && counts[cat] !== undefined) { + countSpan.textContent = counts[cat]; + } + }); + }); + } // Helper: Convert UTC string to browser local time + function utcToLocal(utcString) { + if (!utcString) return ''; + // Parse as UTC + var utcDate = new Date(utcString + ' UTC'); + if (isNaN(utcDate.getTime())) return utcString; + return utcDate.toLocaleString(); + } + + // Get currently active category + function getActiveCategory() { + var activeBtn = document.querySelector('.category-btn.active'); + if (activeBtn) { + var cat = activeBtn.getAttribute('data-category'); + return cat === '__all__' ? '' : cat; + } + return ''; + } + + // Perform search and update table + function performNotesSearch() { + var searchTerm = searchBox ? searchBox.value.trim() : ''; + var selectedCat = getActiveCategory(); + var formData = new FormData(); + formData.append('cat', selectedCat); + // Only send search if 3+ chars, else send empty search + if (searchTerm.length >= 3) { + formData.append('search', searchTerm); + } else { + formData.append('search', ''); + } + fetch(base_url + 'index.php/notes/search', { + method: 'POST', + body: formData + }) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.json(); + }) + .then(data => { + var tbody = ''; + if (data.length === 0) { + tbody = '' + lang_notes_not_found + ''; + } else { + data.forEach(function(note) { + tbody += '' + + '' + (note.cat ? note.cat : '') + '' + + '' + (note.title ? note.title : '') + '' + + '' + (note.last_modified ? note.last_modified : '') + '' + + ''; + }); + } + if (notesTableBody) { + notesTableBody.innerHTML = tbody; + } + }) + .catch(error => { + if (notesTableBody) { + notesTableBody.innerHTML = '' + lang_notes_error_loading + ': ' + error.message + ''; + } + }); + } + + // Sorting logic for notes table + var sortState = { + column: 3, // Default to 'Last Modification' column + direction: 'desc' // Show latest modified at top + }; + var columnHeaders = domCache.notesTable ? domCache.notesTable.querySelectorAll('thead th') : []; + + // Add sorting indicators and click handlers (only for supported columns) + columnHeaders.forEach(function(th, idx) { + var span = document.createElement('span'); + span.className = 'dt-column-order'; + th.appendChild(span); + if (SORT_COLUMN_MAP[idx]) { + span.setAttribute('role', 'button'); + span.setAttribute('aria-label', th.textContent + ': ' + lang_notes_sort); + span.setAttribute('tabindex', '0'); + th.style.cursor = 'pointer'; + th.addEventListener('click', function() { + if (sortState.column !== idx) { + sortState.column = idx; + sortState.direction = 'asc'; + } else if (sortState.direction === 'asc') { + sortState.direction = 'desc'; + } else if (sortState.direction === 'desc') { + sortState.direction = null; + sortState.column = null; + } else { + sortState.direction = 'asc'; + } + updateSortIndicators(); + performNotesSearch(); + }); + } else { + th.style.cursor = 'default'; + } + }); + + // Update sort indicators in the header + function updateSortIndicators() { + columnHeaders.forEach(function(th, idx) { + var span = th.querySelector('.dt-column-order'); + if (!span) return; + span.textContent = ''; + if (sortState.column === idx) { + if (sortState.direction === 'asc') { + span.textContent = '▲'; + } else if (sortState.direction === 'desc') { + span.textContent = '▼'; + } + } + }); + } + + // Server-side pagination, sorting, and search integration + var currentPage = 1; + var totalPages = 1; + var lastResponseTotal = 0; + window.lastNotesData = []; + + // Render pagination controls + function renderPagination() { + if (!domCache.paginationContainer) return; + domCache.paginationContainer.innerHTML = ''; + if (totalPages <= 1) return; + var ul = document.createElement('ul'); + ul.className = 'pagination pagination-sm'; + for (var i = 1; i <= totalPages; i++) { + var li = document.createElement('li'); + li.className = 'page-item' + (i === currentPage ? ' active' : ''); + var a = document.createElement('a'); + a.className = 'page-link'; + a.href = '#'; + a.textContent = i; + a.addEventListener('click', function(e) { + e.preventDefault(); + var page = parseInt(this.textContent); + if (page !== currentPage) { + currentPage = page; + performNotesSearch(); + } + }); + li.appendChild(a); + ul.appendChild(li); + } + domCache.paginationContainer.appendChild(ul); + } + + // Simple Markdown to plain text conversion for tooltip preview + function markdownToText(md) { + // Remove code blocks + md = md.replace(/```[\s\S]*?```/g, ''); + // Remove inline code + md = md.replace(/`[^`]*`/g, ''); + // Remove images + md = md.replace(/!\[.*?\]\(.*?\)/g, ''); + // Remove links but keep text + md = md.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1'); + // Remove headings + md = md.replace(/^#{1,6}\s*/gm, ''); + // Remove blockquotes + md = md.replace(/^>\s?/gm, ''); + // Remove emphasis + md = md.replace(/(\*\*|__)(.*?)\1/g, '$2'); + md = md.replace(/(\*|_)(.*?)\1/g, '$2'); + // Remove lists + md = md.replace(/^\s*([-*+]|\d+\.)\s+/gm, ''); + // Remove horizontal rules + md = md.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, ''); + // Remove HTML tags + md = md.replace(/<[^>]+>/g, ''); + // Collapse whitespace + md = md.replace(/\s+/g, ' ').trim(); + return md; + } + + // Render notes table with data + function renderNotesTable(data) { + // Helper function to get translated category name + function getTranslatedCategory(categoryKey) { + if (window.categoryTranslations && window.categoryTranslations[categoryKey]) { + return window.categoryTranslations[categoryKey]; + } + return categoryKey || ''; + } + + var tbody = ''; + if (data.length === 0) { + tbody = '' + lang_notes_not_found + ''; + } else { + data.forEach(function(note) { + // Strip HTML/Markdown and truncate to 100 chars for tooltip + var rawContent = note.note ? note.note : ''; + // Use a more robust Markdown-to-text conversion + var plainContent = markdownToText(rawContent); + // Truncate to 100 chars + var preview = plainContent.length > 100 ? plainContent.substring(0, 100) + '…' : plainContent; + tbody += '' + + '' + getTranslatedCategory(note.cat) + '' + + '' + (note.title ? note.title : '') + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + (note.cat === 'Contacts' + ? '' + : '' + ) + + '' + + '
' + + '' + + ''; + }); + } + if (domCache.notesTableBody) { + domCache.notesTableBody.innerHTML = tbody; + // After rendering, initialize table elements + initializeTableElements(); + } + updateSortIndicators(); + } + + // Modal confirmation for delete and duplicate + window.confirmDeleteNote = function(noteId) { + showBootstrapModal(lang_notes_delete, lang_notes_delete_confirmation, function() { + deleteNote(noteId); + }); + }; + window.confirmDuplicateNote = function(noteId) { + var note = (window.lastNotesData || []).find(function(n) { return n.id == noteId; }); + if (note && note.cat === 'Contacts') { + showBootstrapModal(lang_notes_duplication_disabled_short, lang_notes_duplication_disabled, function(){}); + return; + } + showBootstrapModal(lang_notes_duplicate, lang_notes_duplicate_confirmation, function() { + duplicateNote(noteId); + }); + }; + + // Actions for delete and duplicate + function deleteNote(noteId) { + fetch(base_url + 'index.php/notes/delete/' + noteId, { method: 'POST' }) + .then(() => { + // Check if we need to go to previous page after deletion + // If we're on the last page and it only has 1 item, go back one page + var currentPageItemCount = window.lastNotesData ? window.lastNotesData.length : 0; + if (currentPage > 1 && currentPageItemCount === 1) { + currentPage = currentPage - 1; + } + performNotesSearch(); + reloadCategoryCounters(); + }); + } + + // Duplicate note via POST with timestamp + function duplicateNote(noteId) { + // Get local timestamp + var now = new Date(); + var timestamp = now.toLocaleString(); + var formData = new FormData(); + formData.append('timestamp', timestamp); + fetch(base_url + 'index.php/notes/duplicate/' + noteId, { + method: 'POST', + body: formData + }) + .then(() => { + performNotesSearch(); + reloadCategoryCounters(); + }); + } + + // Bootstrap modal helper + function showBootstrapModal(title, message, onConfirm) { + var modalId = 'confirmModal_' + Math.random().toString(36).substr(2, 9); + var modalHtml = ''; + var modalDiv = document.createElement('div'); + modalDiv.innerHTML = modalHtml; + document.body.appendChild(modalDiv); + var modalEl = modalDiv.querySelector('.modal'); + var modal; + try { + modal = new bootstrap.Modal(modalEl, { backdrop: 'static' }); + modal.show(); + } catch (e) { + document.body.removeChild(modalDiv); + if (confirm(message)) { + onConfirm(); + } + return; + } + modalDiv.querySelector('#confirmModalBtn_' + modalId).onclick = function() { + modal.hide(); + setTimeout(function() { document.body.removeChild(modalDiv); }, 300); + onConfirm(); + }; + modalDiv.querySelector('[data-bs-dismiss="modal"]').onclick = function() { + modal.hide(); + setTimeout(function() { document.body.removeChild(modalDiv); }, 300); + }; + } + + // Patch performNotesSearch to use server-side pagination and sorting + performNotesSearch = function() { + var searchTerm = domCache.searchBox ? domCache.searchBox.value.trim() : ''; + var selectedCat = getActiveCategory(); + var sortColIdx = sortState.column; + var sortDir = sortState.direction; + var sortCol = (sortColIdx !== null && SORT_COLUMN_MAP[sortColIdx]) ? SORT_COLUMN_MAP[sortColIdx] : null; + var formData = new FormData(); + formData.append('cat', selectedCat); + formData.append('search', searchTerm.length >= SEARCH_MIN_LENGTH ? searchTerm : ''); + formData.append('page', currentPage); + formData.append('per_page', NOTES_PER_PAGE); + formData.append('sort_col', sortCol || ''); + formData.append('sort_dir', sortDir || ''); + fetch(base_url + 'index.php/notes/search', { + method: 'POST', + body: formData + }) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.json(); + }) + .then(resp => { + var data = (resp && Array.isArray(resp.notes)) ? resp.notes : []; + window.lastNotesData = data; + lastResponseTotal = resp.total || 0; + totalPages = Math.max(1, Math.ceil(lastResponseTotal / NOTES_PER_PAGE)); + if (currentPage > totalPages) currentPage = totalPages; + renderNotesTable(data); + renderPagination(); + reloadCategoryCounters(); + }) + .catch(error => { + if (domCache.notesTableBody) { + domCache.notesTableBody.innerHTML = '' + lang_notes_error_loading + ':' + error.message + ''; + } + if (domCache.paginationContainer) { + domCache.paginationContainer.innerHTML = ''; + } + }); + }; + + // Reset to first page on search, sort, or category change + if (domCache.categoryButtons && domCache.notesTableBody) { + domCache.categoryButtons.forEach(function(btn) { + btn.addEventListener('click', function() { + domCache.categoryButtons.forEach(function(b) { b.classList.remove('active'); }); + btn.classList.add('active'); + currentPage = 1; + performNotesSearch(); + }); + }); + } + if (domCache.searchBox) { + domCache.searchBox.addEventListener('input', function() { + currentPage = 1; + performNotesSearch(); + }); + } + if (domCache.resetBtn) { + domCache.resetBtn.addEventListener('click', function() { + if (domCache.searchBox) domCache.searchBox.value = ''; + currentPage = 1; + performNotesSearch(); + }); + } + + // Initial render - only if we have the necessary elements + if (domCache.notesTableBody) { + performNotesSearch(); + } });