|
|
@@ -97,16 +97,18 @@ function trace(string $message): void {
|
|
97
|
97
|
|
|
98
|
98
|
/// Represents a journal post.
|
|
99
|
99
|
class Post {
|
|
100
|
|
- public int $post_id;
|
|
|
100
|
+ public ?int $post_id;
|
|
101
|
101
|
public string $body;
|
|
102
|
102
|
public int $author_id;
|
|
103
|
103
|
public int $created;
|
|
|
104
|
+ public ?int $updated;
|
|
104
|
105
|
|
|
105
|
106
|
function __construct(array $row) {
|
|
106
|
107
|
$this->post_id = $row['rowid'];
|
|
107
|
108
|
$this->body = $row['body'];
|
|
108
|
109
|
$this->author_id = $row['author_id'];
|
|
109
|
110
|
$this->created = $row['created'];
|
|
|
111
|
+ $this->updated = $row['updated'];
|
|
110
|
112
|
}
|
|
111
|
113
|
|
|
112
|
114
|
/// Normalizes the body of a post.
|
|
|
@@ -167,6 +169,7 @@ class Post {
|
|
167
|
169
|
public static function get_posts(
|
|
168
|
170
|
int $user_id,
|
|
169
|
171
|
int $count = RECENT_POSTS_PER_PAGE,
|
|
|
172
|
+ ?string $query = null,
|
|
170
|
173
|
?int $before_time = null): array {
|
|
171
|
174
|
$sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id';
|
|
172
|
175
|
$args = array(
|
|
|
@@ -177,6 +180,16 @@ class Post {
|
|
177
|
180
|
$sql .= ' AND created < :before_time';
|
|
178
|
181
|
$args[':before_time'] = $before_time;
|
|
179
|
182
|
}
|
|
|
183
|
+ $search_where = '';
|
|
|
184
|
+ if ($query) {
|
|
|
185
|
+ foreach (explode(' ', $query) as $i => $term) {
|
|
|
186
|
+ if (strlen($term) == 0) continue;
|
|
|
187
|
+ $symbol = ":wordpattern{$i}";
|
|
|
188
|
+ $search_where .= " AND body LIKE $symbol ESCAPE '!'";
|
|
|
189
|
+ $args[$symbol] = '%' . Database::escape_like($term, '!') . '%';
|
|
|
190
|
+ }
|
|
|
191
|
+ $sql .= $search_where;
|
|
|
192
|
+ }
|
|
180
|
193
|
$sql .= ' ORDER BY created DESC LIMIT :count;';
|
|
181
|
194
|
$posts = Database::query_objects('Post', $sql, $args);
|
|
182
|
195
|
|
|
|
@@ -189,12 +202,8 @@ class Post {
|
|
189
|
202
|
if ($before_time) {
|
|
190
|
203
|
// We're paged forward. Check if there are newer posts.
|
|
191
|
204
|
$sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id AND ' .
|
|
192
|
|
- 'created >= :before_time ORDER BY created ASC LIMIT :count;';
|
|
193
|
|
- $args = array(
|
|
194
|
|
- ':author_id' => $user_id,
|
|
195
|
|
- ':count' => $count + 1, // to see if it's the newest page
|
|
196
|
|
- ':before_time' => $before_time,
|
|
197
|
|
- );
|
|
|
205
|
+ 'created >= :before_time ' . $search_where . ' ORDER BY created ASC LIMIT :count;';
|
|
|
206
|
+ // Reusing same $args
|
|
198
|
207
|
$newer_posts = Database::query_objects('Post', $sql, $args);
|
|
199
|
208
|
if (sizeof($newer_posts) > $count) {
|
|
200
|
209
|
$prev_date = $newer_posts[array_key_last($newer_posts)]->created;
|
|
|
@@ -203,9 +212,48 @@ class Post {
|
|
203
|
212
|
$prev_page = BASE_URL;
|
|
204
|
213
|
}
|
|
205
|
214
|
}
|
|
|
215
|
+ if ($query) {
|
|
|
216
|
+ if ($prev_page) {
|
|
|
217
|
+ $prev_page .= (str_contains($prev_page, '?') ? '&' : '?') .
|
|
|
218
|
+ 'search=' . urlencode($query);
|
|
|
219
|
+ }
|
|
|
220
|
+ if ($next_page) {
|
|
|
221
|
+ $next_page .= (str_contains($next_page, '?') ? '&' : '?') .
|
|
|
222
|
+ 'search=' . urlencode($query);
|
|
|
223
|
+ }
|
|
|
224
|
+ }
|
|
206
|
225
|
|
|
207
|
226
|
return array($posts, $prev_page, $next_page);
|
|
208
|
227
|
}
|
|
|
228
|
+
|
|
|
229
|
+ /// Fetches a post by its post ID.
|
|
|
230
|
+ /// @param int $post_id ID of the post.
|
|
|
231
|
+ /// @return ?Post The Post, or null if not found.
|
|
|
232
|
+ public static function get_by_id(int $post_id): ?Post {
|
|
|
233
|
+ $sql = 'SELECT rowid, * FROM posts WHERE rowid=:post_id;';
|
|
|
234
|
+ $args = array(':post_id' => $post_id);
|
|
|
235
|
+ return Database::query_object('Post', $sql, $args);
|
|
|
236
|
+ }
|
|
|
237
|
+
|
|
|
238
|
+ /// Deletes this post.
|
|
|
239
|
+ public function delete(): void {
|
|
|
240
|
+ $sql = 'DELETE FROM posts WHERE rowid=:post_id;';
|
|
|
241
|
+ $args = array(':post_id' => $this->post_id);
|
|
|
242
|
+ Database::query($sql, $args);
|
|
|
243
|
+ $this->post_id = null;
|
|
|
244
|
+ }
|
|
|
245
|
+
|
|
|
246
|
+ /// Update text of post.
|
|
|
247
|
+ public function update(string $new_body): void {
|
|
|
248
|
+ $new_body = self::normalize_body($new_body);
|
|
|
249
|
+ $sql = 'UPDATE posts SET body=:body, updated=:updated WHERE rowid=:rowid;';
|
|
|
250
|
+ $args = array(
|
|
|
251
|
+ ':body' => $new_body,
|
|
|
252
|
+ ':updated' => time(),
|
|
|
253
|
+ ':rowid' => $this->post_id,
|
|
|
254
|
+ );
|
|
|
255
|
+ Database::query($sql, $args);
|
|
|
256
|
+ }
|
|
209
|
257
|
}
|
|
210
|
258
|
|
|
211
|
259
|
/// Represents a user.
|
|
|
@@ -477,9 +525,12 @@ class Database {
|
|
477
|
525
|
/// and named column values.
|
|
478
|
526
|
public static function query(string $sql, array $params = array()): bool|array {
|
|
479
|
527
|
$db = new SQLite3(DB_PATH);
|
|
|
528
|
+ trace('SQL: ' . $sql);
|
|
480
|
529
|
$stmt = $db->prepare($sql);
|
|
481
|
530
|
foreach ($params as $name => $value) {
|
|
482
|
|
- $stmt->bindValue($name, $value, self::sqlite_type($value));
|
|
|
531
|
+ $type = self::sqlite_type($value);
|
|
|
532
|
+ $stmt->bindValue($name, $value, $type);
|
|
|
533
|
+ trace("\tbind {$name} => {$value} ({$type})");
|
|
483
|
534
|
}
|
|
484
|
535
|
$result = $stmt->execute();
|
|
485
|
536
|
if (gettype($result) == 'bool') {
|
|
|
@@ -514,6 +565,18 @@ class Database {
|
|
514
|
565
|
fatal_error("Bad datatype in sqlite statement");
|
|
515
|
566
|
}
|
|
516
|
567
|
}
|
|
|
568
|
+
|
|
|
569
|
+ /// Escapes a string for use in a LIKE clause.
|
|
|
570
|
+ /// @param string $value String to escape.
|
|
|
571
|
+ /// @param string $escape Character to use for escaping. Default is !
|
|
|
572
|
+ /// @return string Escaped string
|
|
|
573
|
+ public static function escape_like(string $value, string $escape='!'): string {
|
|
|
574
|
+ $s = $value;
|
|
|
575
|
+ $s = str_replace($escape, $escape . $escape, $s); // escape char
|
|
|
576
|
+ $s = str_replace('%', $escape . '%', $s); // any chars
|
|
|
577
|
+ $s = str_replace('_', $escape . '_', $s); // one char
|
|
|
578
|
+ return $s;
|
|
|
579
|
+ }
|
|
517
|
580
|
}
|
|
518
|
581
|
|
|
519
|
582
|
/// App configuration management. All config is stored in the 'config' table in
|
|
|
@@ -717,11 +780,13 @@ class HTMLPage {
|
|
717
|
780
|
if (User::$current) {
|
|
718
|
781
|
print(<<<HTML
|
|
719
|
782
|
<div class="menu-container">
|
|
720
|
|
- <details>
|
|
721
|
|
- <summary><span>☰</span></summary>
|
|
|
783
|
+ <details class="menu">
|
|
|
784
|
+ <summary class="no-indicator menu-button"><span>☰</span></summary>
|
|
722
|
785
|
|
|
723
|
786
|
<ul>
|
|
724
|
|
- <a href="?logout"><li>Log out</li></a>
|
|
|
787
|
+ <li><a href="?search">Search</a></li>
|
|
|
788
|
+ <li class="menu-divider"></li>
|
|
|
789
|
+ <li class="logout-item destructive"><a href="?logout">Log out</a></li>
|
|
725
|
790
|
</ul>
|
|
726
|
791
|
</details>
|
|
727
|
792
|
</div>
|
|
|
@@ -758,21 +823,42 @@ class HTMLPage {
|
|
758
|
823
|
}
|
|
759
|
824
|
|
|
760
|
825
|
public static function render_post_form(): void {
|
|
761
|
|
- $body = array_key_exists(SESSION_KEY_POST_BODY, $_SESSION) ? $_SESSION[SESSION_KEY_POST_BODY] : '';
|
|
762
|
|
- unset($_SESSION[SESSION_KEY_POST_BODY]);
|
|
|
826
|
+ $action = 'post';
|
|
|
827
|
+ $verb = 'Post';
|
|
|
828
|
+ $body = '';
|
|
|
829
|
+ if ($edit_id = validate($_GET, 'edit', INPUT_TYPE_INT, required: false)) {
|
|
|
830
|
+ if ($post = Post::get_by_id($edit_id)) {
|
|
|
831
|
+ $body = $post->body;
|
|
|
832
|
+ $action = 'edit';
|
|
|
833
|
+ $verb = 'Update';
|
|
|
834
|
+ } else {
|
|
|
835
|
+ unset($edit_id);
|
|
|
836
|
+ }
|
|
|
837
|
+ } elseif (array_key_exists(SESSION_KEY_POST_BODY, $_SESSION)) {
|
|
|
838
|
+ $body = $_SESSION[SESSION_KEY_POST_BODY];
|
|
|
839
|
+ unset($_SESSION[SESSION_KEY_POST_BODY]);
|
|
|
840
|
+ }
|
|
|
841
|
+ $body_html = htmlentities($body);
|
|
763
|
842
|
print(<<<HTML
|
|
764
|
843
|
<form id="post-form" method="POST">
|
|
765
|
|
- <div class="text-container"><textarea name="body" placeholder="Your journal post here…">$body</textarea></div>
|
|
766
|
|
- <input type="hidden" name="action" value="post" />
|
|
767
|
|
- <div class="submit-post"><input type="submit" value="Post" /></div>
|
|
|
844
|
+ <div class="text-container"><textarea name="body" placeholder="Your journal post here…">$body_html</textarea></div>
|
|
|
845
|
+ <input type="hidden" name="action" value="{$action}" />
|
|
|
846
|
+ HTML
|
|
|
847
|
+ );
|
|
|
848
|
+ if ($edit_id) {
|
|
|
849
|
+ print("<input type=\"hidden\" name=\"edit_id\" value=\"{$edit_id}\" />");
|
|
|
850
|
+ }
|
|
|
851
|
+ print(<<<HTML
|
|
|
852
|
+ <div class="submit-post"><input type="submit" value="{$verb}" /></div>
|
|
768
|
853
|
</form>
|
|
769
|
854
|
HTML
|
|
770
|
855
|
);
|
|
771
|
856
|
}
|
|
772
|
857
|
|
|
773
|
858
|
public static function render_recent_posts(): void {
|
|
|
859
|
+ $query = validate($_GET, 'search', INPUT_TYPE_STRING | INPUT_TYPE_TRIMMED, required: false);
|
|
774
|
860
|
$before = validate($_GET, 'before', INPUT_TYPE_INT, required: false);
|
|
775
|
|
- [ $posts, $prev_url, $next_url ] = Post::get_posts(User::$current->user_id, before_time: $before);
|
|
|
861
|
+ [ $posts, $prev_url, $next_url ] = Post::get_posts(User::$current->user_id, query: $query, before_time: $before);
|
|
776
|
862
|
print("<div class=\"post-container\">\n");
|
|
777
|
863
|
if ($prev_url !== null) {
|
|
778
|
864
|
print("<div class=\"previous\"><a href=\"{$prev_url}\">Previous</a></div>\n");
|
|
|
@@ -788,12 +874,18 @@ class HTMLPage {
|
|
788
|
874
|
|
|
789
|
875
|
/// Encodes a post body as HTML. Inserts linebreaks and paragraph tags.
|
|
790
|
876
|
private static function post_body_html(string $body): string {
|
|
|
877
|
+ $html_p_start = "<p>";
|
|
|
878
|
+ $html_p_end = "</p>\n";
|
|
|
879
|
+ $html_newline = "<br/>\n";
|
|
791
|
880
|
$body_html = htmlentities($body);
|
|
792
|
881
|
// Single newlines are turned into linebreaks.
|
|
793
|
|
- $body_html = str_replace("\n", "<br/>\n", $body_html);
|
|
|
882
|
+ $body_html = str_replace("\n", $html_newline, $body_html);
|
|
794
|
883
|
// Pairs of newlines are turned into paragraph separators.
|
|
795
|
|
- $paragraphs = explode("<br/>\n<br/>\n", $body_html);
|
|
796
|
|
- $body_html = "<p>" . implode("</p>\n\n<p>", $paragraphs) . "</p>\n";
|
|
|
884
|
+ $body_html = $html_p_start .
|
|
|
885
|
+ str_replace($html_newline . $html_newline,
|
|
|
886
|
+ $html_p_end . $html_p_start,
|
|
|
887
|
+ $body_html) .
|
|
|
888
|
+ $html_p_end;
|
|
797
|
889
|
return $body_html;
|
|
798
|
890
|
}
|
|
799
|
891
|
|
|
|
@@ -804,17 +896,51 @@ class HTMLPage {
|
|
804
|
896
|
<div class="post">
|
|
805
|
897
|
<article>
|
|
806
|
898
|
<div class="post-body">{$body_html}</div>
|
|
807
|
|
- <footer>
|
|
808
|
|
- <div class="post-date">
|
|
809
|
|
- Posted {$date}
|
|
810
|
|
- </div>
|
|
811
|
|
- </footer>
|
|
|
899
|
+ <div class="post-footer">
|
|
|
900
|
+ <footer class="secondary-text">
|
|
|
901
|
+ <div class="post-date">
|
|
|
902
|
+ Posted {$date}
|
|
|
903
|
+ HTML
|
|
|
904
|
+ );
|
|
|
905
|
+ if ($post->updated && $post->updated != $post->created) {
|
|
|
906
|
+ print('<br/>(updated ' . localized_date_string($post->updated) . ')');
|
|
|
907
|
+ }
|
|
|
908
|
+ print(<<<HTML
|
|
|
909
|
+ </div>
|
|
|
910
|
+ <details class="post-actions menu">
|
|
|
911
|
+ <summary class="no-indicator menu-button"><span>actions</span></summary>
|
|
|
912
|
+
|
|
|
913
|
+ <ul>
|
|
|
914
|
+ <li><a href="?edit={$post->post_id}">Edit</a></li>
|
|
|
915
|
+ <li class="menu-divider"></li>
|
|
|
916
|
+ <li class="post-action-delete destructive"><a href="?delete={$post->post_id}">Delete</a></li>
|
|
|
917
|
+ </ul>
|
|
|
918
|
+ </details>
|
|
|
919
|
+ </footer>
|
|
|
920
|
+ </div>
|
|
812
|
921
|
</article>
|
|
813
|
922
|
</div>
|
|
814
|
923
|
HTML
|
|
815
|
924
|
);
|
|
816
|
925
|
}
|
|
817
|
926
|
|
|
|
927
|
+ public static function render_search_form(): void {
|
|
|
928
|
+ $q = validate($_GET, 'search', INPUT_TYPE_STRING | INPUT_TYPE_TRIMMED, required: false);
|
|
|
929
|
+ $q_html = htmlentities($q);
|
|
|
930
|
+ $cancel = BASE_URL;
|
|
|
931
|
+ print(<<<HTML
|
|
|
932
|
+ <form id="search-form" method="GET">
|
|
|
933
|
+ <div>
|
|
|
934
|
+ <label for="search">Search:</label>
|
|
|
935
|
+ <input type="text" name="search" id="search" value="{$q_html}" autocapitalize="off" />
|
|
|
936
|
+ <input type="submit" value="Search" />
|
|
|
937
|
+ <a href="{$cancel}">Cancel</a>
|
|
|
938
|
+ </div>
|
|
|
939
|
+ </form>
|
|
|
940
|
+ HTML
|
|
|
941
|
+ );
|
|
|
942
|
+ }
|
|
|
943
|
+
|
|
818
|
944
|
public static function render_sign_in_form(): void {
|
|
819
|
945
|
print(<<<HTML
|
|
820
|
946
|
<form id="signin-form" method="POST">
|
|
|
@@ -888,7 +1014,15 @@ switch ($_SERVER['REQUEST_METHOD']) {
|
|
888
|
1014
|
HTMLPage::render_page_start();
|
|
889
|
1015
|
HTMLPage::render_error_if_needed();
|
|
890
|
1016
|
if (User::$current) {
|
|
891
|
|
- HTMLPage::render_post_form();
|
|
|
1017
|
+ if ($delete_id = validate($_GET, 'delete', INPUT_TYPE_INT, required: false)) {
|
|
|
1018
|
+ Post::get_by_id($delete_id)?->delete();
|
|
|
1019
|
+ HTMLPage::redirect_home();
|
|
|
1020
|
+ }
|
|
|
1021
|
+ if (array_key_exists('search', $_GET)) {
|
|
|
1022
|
+ HTMLPage::render_search_form();
|
|
|
1023
|
+ } else {
|
|
|
1024
|
+ HTMLPage::render_post_form();
|
|
|
1025
|
+ }
|
|
892
|
1026
|
HTMLPage::render_recent_posts();
|
|
893
|
1027
|
} elseif (User::any_exist()) {
|
|
894
|
1028
|
HTMLPage::render_sign_in_form();
|
|
|
@@ -912,6 +1046,15 @@ switch ($_SERVER['REQUEST_METHOD']) {
|
|
912
|
1046
|
}
|
|
913
|
1047
|
Post::create($body, $author, $created);
|
|
914
|
1048
|
break;
|
|
|
1049
|
+ case 'edit':
|
|
|
1050
|
+ $body = validate($_POST, 'body', $nonempty_str_type);
|
|
|
1051
|
+ $edit_id = validate($_POST, 'edit_id', INPUT_TYPE_INT);
|
|
|
1052
|
+ if (!User::$current) {
|
|
|
1053
|
+ // Not logged in. Save body for populating once they sign in.
|
|
|
1054
|
+ fatal_error('Please sign in to edit.');
|
|
|
1055
|
+ }
|
|
|
1056
|
+ Post::get_by_id($edit_id)?->update($body);
|
|
|
1057
|
+ break;
|
|
915
|
1058
|
case 'createaccount':
|
|
916
|
1059
|
$username = validate($_POST, 'username', INPUT_TYPE_USERNAME);
|
|
917
|
1060
|
$password = validate($_POST, 'password', $password_type);
|