Fossil SCM

fossil-scm / src / json_wiki.c
Blame History Raw 642 lines
1
#ifdef FOSSIL_ENABLE_JSON
2
/*
3
** Copyright (c) 2011-12 D. Richard Hipp
4
**
5
** This program is free software; you can redistribute it and/or
6
** modify it under the terms of the Simplified BSD License (also
7
** known as the "2-Clause License" or "FreeBSD License".)
8
**
9
** This program is distributed in the hope that it will be useful,
10
** but without any warranty; without even the implied warranty of
11
** merchantability or fitness for a particular purpose.
12
**
13
** Author contact information:
14
** [email protected]
15
** http://www.hwaci.com/drh/
16
**
17
*/
18
#include "VERSION.h"
19
#include "config.h"
20
#include "json_wiki.h"
21
22
#if INTERFACE
23
#include "json_detail.h"
24
#endif
25
26
static cson_value * json_wiki_create(void);
27
static cson_value * json_wiki_get(void);
28
static cson_value * json_wiki_list(void);
29
static cson_value * json_wiki_preview(void);
30
static cson_value * json_wiki_save(void);
31
static cson_value * json_wiki_diff(void);
32
/*
33
** Mapping of /json/wiki/XXX commands/paths to callbacks.
34
*/
35
static const JsonPageDef JsonPageDefs_Wiki[] = {
36
{"create", json_wiki_create, 0},
37
{"diff", json_wiki_diff, 0},
38
{"get", json_wiki_get, 0},
39
{"list", json_wiki_list, 0},
40
{"preview", json_wiki_preview, 0},
41
{"save", json_wiki_save, 0},
42
{"timeline", json_timeline_wiki,0},
43
/* Last entry MUST have a NULL name. */
44
{NULL,NULL,0}
45
};
46
47
48
/*
49
** Implements the /json/wiki family of pages/commands.
50
**
51
*/
52
cson_value * json_page_wiki(void){
53
return json_page_dispatch_helper(JsonPageDefs_Wiki);
54
}
55
56
/*
57
** Returns the UUID for the given wiki blob RID, or NULL if not
58
** found. The returned string is allocated via db_text() and must be
59
** free()d by the caller.
60
*/
61
char * json_wiki_get_uuid_for_rid( int rid )
62
{
63
return db_text(NULL,
64
"SELECT b.uuid FROM tag t, tagxref x, blob b"
65
" WHERE x.tagid=t.tagid AND t.tagname GLOB 'wiki-*' "
66
" AND b.rid=x.rid AND b.rid=%d"
67
" ORDER BY x.mtime DESC LIMIT 1",
68
rid
69
);
70
}
71
72
/*
73
** Tries to load a wiki page from the given rid creates a JSON object
74
** representation of it. If the page is not found then NULL is
75
** returned. If contentFormat is positive then the page content
76
** is HTML-ized using fossil's conventional wiki format, if it is
77
** negative then no parsing is performed, if it is 0 then the content
78
** is not returned in the response. If contentFormat is 0 then the
79
** contentSize reflects the number of bytes, not characters, stored in
80
** the page.
81
**
82
** The returned value, if not NULL, is-a JSON Object owned by the
83
** caller. If it returns NULL then it may set g.json's error state.
84
*/
85
cson_value * json_get_wiki_page_by_rid(int rid, int contentFormat){
86
Manifest * pWiki = NULL;
87
if( NULL == (pWiki = manifest_get(rid, CFTYPE_WIKI, 0)) ){
88
json_set_err( FSL_JSON_E_UNKNOWN,
89
"Error reading wiki page from manifest (rid=%d).",
90
rid );
91
return NULL;
92
}else{
93
unsigned int len = 0;
94
cson_object * pay = cson_new_object();
95
char const * zBody = pWiki->zWiki;
96
char const * zFormat = NULL;
97
char * zUuid = json_wiki_get_uuid_for_rid(rid);
98
cson_object_set(pay,"name",json_new_string(pWiki->zWikiTitle));
99
cson_object_set(pay,"uuid",json_new_string(zUuid));
100
free(zUuid);
101
zUuid = NULL;
102
if( pWiki->nParent > 0 ){
103
cson_object_set( pay, "parent", json_new_string(pWiki->azParent[0]) )
104
/* Reminder: wiki pages do not branch and have only one parent
105
(except for the initial version, which has no parents). */;
106
}
107
/*cson_object_set(pay,"rid",json_new_int((cson_int_t)rid));*/
108
cson_object_set(pay,"user",json_new_string(pWiki->zUser));
109
cson_object_set(pay,FossilJsonKeys.timestamp,
110
json_julian_to_timestamp(pWiki->rDate));
111
if(0 == contentFormat){
112
cson_object_set(pay,"size",
113
json_new_int((cson_int_t)(zBody?strlen(zBody):0)));
114
}else{
115
if( contentFormat>0 ){/*HTML-ize it*/
116
Blob content = empty_blob;
117
Blob raw = empty_blob;
118
zFormat = "html";
119
if(zBody && *zBody){
120
const char *zMimetype = pWiki->zMimetype;
121
if( zMimetype==0 ) zMimetype = "text/x-fossil-wiki";
122
zMimetype = wiki_filter_mimetypes(zMimetype);
123
blob_append(&raw,zBody,-1);
124
if( fossil_strcmp(zMimetype, "text/x-fossil-wiki")==0 ){
125
wiki_convert(&raw,&content,0);
126
}else if( fossil_strcmp(zMimetype, "text/x-markdown")==0 ){
127
markdown_to_html(&raw,0,&content);
128
}else if( fossil_strcmp(zMimetype, "text/plain")==0 ){
129
htmlize_to_blob(&content,blob_str(&raw),blob_size(&raw));
130
}else{
131
json_set_err( FSL_JSON_E_UNKNOWN,
132
"Unsupported MIME type '%s' for wiki page '%s'.",
133
zMimetype, pWiki->zWikiTitle );
134
blob_reset(&content);
135
blob_reset(&raw);
136
cson_free_object(pay);
137
manifest_destroy(pWiki);
138
return NULL;
139
}
140
len = (unsigned int)blob_size(&content);
141
}
142
cson_object_set(pay,"size",json_new_int((cson_int_t)len));
143
cson_object_set(pay,"content",
144
cson_value_new_string(blob_buffer(&content),len));
145
blob_reset(&content);
146
blob_reset(&raw);
147
}else{/*raw format*/
148
zFormat = "raw";
149
len = zBody ? strlen(zBody) : 0;
150
cson_object_set(pay,"size",json_new_int((cson_int_t)len));
151
cson_object_set(pay,"content",cson_value_new_string(zBody,len));
152
}
153
cson_object_set(pay,"contentFormat",json_new_string(zFormat));
154
155
}
156
/*TODO: add 'T' (tag) fields*/
157
/*TODO: add the 'A' card (file attachment) entries?*/
158
manifest_destroy(pWiki);
159
return cson_object_value(pay);
160
}
161
}
162
163
/*
164
** Searches for the latest version of a wiki page with the given
165
** name. If found it behaves like json_get_wiki_page_by_rid(theRid,
166
** contentFormat), else it returns NULL.
167
*/
168
cson_value * json_get_wiki_page_by_name(char const * zPageName,
169
int contentFormat){
170
int rid;
171
rid = db_int(0,
172
"SELECT x.rid FROM tag t, tagxref x, blob b"
173
" WHERE x.tagid=t.tagid AND t.tagname='wiki-%q' "
174
" AND b.rid=x.rid"
175
" ORDER BY x.mtime DESC LIMIT 1",
176
zPageName
177
);
178
if( 0==rid ){
179
json_set_err( FSL_JSON_E_RESOURCE_NOT_FOUND, "Wiki page not found: %s",
180
zPageName );
181
return NULL;
182
}
183
return json_get_wiki_page_by_rid(rid, contentFormat);
184
}
185
186
187
/*
188
** Searches json_find_option_ctr("format",NULL,"f") for a flag.
189
** If not found it returns defaultValue else it returns a value
190
** depending on the first character of the format option:
191
**
192
** [h]tml = 1
193
** [n]one = 0
194
** [r]aw = -1
195
**
196
** The return value is intended for use with
197
** json_get_wiki_page_by_rid() and friends.
198
*/
199
int json_wiki_get_content_format_flag( int defaultValue ){
200
int contentFormat = defaultValue;
201
char const * zFormat = json_find_option_cstr("format",NULL,"f");
202
if( !zFormat || !*zFormat ){
203
return contentFormat;
204
}
205
else if('r'==*zFormat){
206
contentFormat = -1;
207
}
208
else if('h'==*zFormat){
209
contentFormat = 1;
210
}
211
else if('n'==*zFormat){
212
contentFormat = 0;
213
}
214
return contentFormat;
215
}
216
217
/*
218
** Helper for /json/wiki/get and /json/wiki/preview. At least one of
219
** zPageName (wiki page name) or zSymname must be set to a
220
** non-empty/non-NULL value. zSymname takes precedence. On success
221
** the result of one of json_get_wiki_page_by_rid() or
222
** json_get_wiki_page_by_name() will be returned (owned by the
223
** caller). On error g.json's error state is set and NULL is returned.
224
*/
225
static cson_value * json_wiki_get_by_name_or_symname(char const * zPageName,
226
char const * zSymname,
227
int contentFormat ){
228
if(!zSymname || !*zSymname){
229
return json_get_wiki_page_by_name(zPageName, contentFormat);
230
}else{
231
int rid = symbolic_name_to_rid( zSymname ? zSymname : zPageName, "w" );
232
if(rid<0){
233
json_set_err(FSL_JSON_E_AMBIGUOUS_UUID,
234
"UUID [%s] is ambiguous.", zSymname);
235
return NULL;
236
}else if(rid==0){
237
json_set_err(FSL_JSON_E_RESOURCE_NOT_FOUND,
238
"UUID [%s] does not resolve to a wiki page.", zSymname);
239
return NULL;
240
}else{
241
return json_get_wiki_page_by_rid(rid, contentFormat);
242
}
243
}
244
}
245
246
/*
247
** Implementation of /json/wiki/get.
248
**
249
*/
250
static cson_value * json_wiki_get(void){
251
char const * zPageName;
252
char const * zSymName = NULL;
253
int contentFormat = -1;
254
if( !g.perm.RdWiki && !g.perm.Read ){
255
json_set_err(FSL_JSON_E_DENIED,
256
"Requires 'o' or 'j' access.");
257
return NULL;
258
}
259
zPageName = json_find_option_cstr2("name",NULL,"n",g.json.dispatchDepth+1);
260
261
zSymName = json_find_option_cstr("uuid",NULL,"u");
262
263
if((!zPageName||!*zPageName) && (!zSymName || !*zSymName)){
264
json_set_err(FSL_JSON_E_MISSING_ARGS,
265
"At least one of the 'name' or 'uuid' arguments must be provided.");
266
return NULL;
267
}
268
269
/* TODO: see if we have a page named zPageName. If not, try to resolve
270
zPageName as a UUID.
271
*/
272
273
contentFormat = json_wiki_get_content_format_flag(contentFormat);
274
return json_wiki_get_by_name_or_symname( zPageName, zSymName, contentFormat );
275
}
276
277
/*
278
** Implementation of /json/wiki/preview.
279
*/
280
static cson_value * json_wiki_preview(void){
281
char const * zContent = NULL;
282
char const * zMime = NULL;
283
cson_string * sContent = NULL;
284
cson_value * pay = NULL;
285
Blob contentOrig = empty_blob;
286
Blob contentHtml = empty_blob;
287
if( !g.perm.WrWiki ){
288
json_set_err(FSL_JSON_E_DENIED,
289
"Requires 'k' access.");
290
return NULL;
291
}
292
293
if(g.json.reqPayload.o){
294
sContent = cson_value_get_string(
295
cson_object_get(g.json.reqPayload.o, "body"));
296
zMime = cson_value_get_cstr(cson_object_get(g.json.reqPayload.o,
297
"mimetype"));
298
}else{
299
sContent = cson_value_get_string(g.json.reqPayload.v);
300
}
301
if(!sContent) {
302
json_set_err(FSL_JSON_E_MISSING_ARGS,
303
"The 'payload' property must be either a string containing the "
304
"Fossil wiki code to preview or an object with body + mimetype "
305
"properties.");
306
return NULL;
307
}
308
zContent = cson_string_cstr(sContent);
309
blob_append( &contentOrig, zContent, (int)cson_string_length_bytes(sContent));
310
zMime = wiki_filter_mimetypes(zMime);
311
if( 0==fossil_strcmp(zMime, "text/x-markdown") ){
312
markdown_to_html(&contentOrig, 0, &contentHtml);
313
}else if( 0==fossil_strcmp(zMime, "text/plain") ){
314
blob_append(&contentHtml, "<pre class='textPlain'>", -1);
315
blob_append(&contentHtml, blob_str(&contentOrig), blob_size(&contentOrig));
316
blob_append(&contentHtml, "</pre>", -1);
317
}else{
318
wiki_convert( &contentOrig, &contentHtml, 0 );
319
}
320
blob_reset( &contentOrig );
321
pay = cson_value_new_string( blob_str(&contentHtml),
322
(unsigned int)blob_size(&contentHtml));
323
blob_reset( &contentHtml );
324
return pay;
325
}
326
327
328
/*
329
** Internal impl of /wiki/save and /wiki/create. If createMode is 0
330
** and the page already exists then a
331
** FSL_JSON_E_RESOURCE_ALREADY_EXISTS error is triggered. If
332
** createMode is false then the FSL_JSON_E_RESOURCE_NOT_FOUND is
333
** triggered if the page does not already exists.
334
**
335
** Note that the error triggered when createMode==0 and no such page
336
** exists is rather arbitrary - we could just as well create the entry
337
** here if it doesn't already exist. With that, save/create would
338
** become one operation. That said, i expect there are people who
339
** would categorize such behaviour as "being too clever" or "doing too
340
** much automatically" (and i would likely agree with them).
341
**
342
** If allowCreateIfNotExists is true then this function will allow a new
343
** page to be created even if createMode is false.
344
*/
345
static cson_value * json_wiki_create_or_save(char createMode,
346
char allowCreateIfNotExists){
347
Blob content = empty_blob; /* wiki page content */
348
cson_value * nameV; /* wiki page name */
349
char const * zPageName; /* cstr form of page name */
350
cson_value * contentV; /* passed-in content */
351
cson_value * emptyContent = NULL; /* placeholder for empty content. */
352
cson_value * payV = NULL; /* payload/return value */
353
cson_string const * jstr = NULL; /* temp for cson_value-to-cson_string
354
conversions. */
355
char const * zMimeType = 0;
356
unsigned int contentLen = 0;
357
int rid;
358
if( (createMode && !g.perm.NewWiki)
359
|| (!createMode && !g.perm.WrWiki)){
360
json_set_err(FSL_JSON_E_DENIED,
361
"Requires '%c' permissions.",
362
(createMode ? 'f' : 'k'));
363
return NULL;
364
}
365
nameV = json_req_payload_get("name");
366
if(!nameV){
367
json_set_err( FSL_JSON_E_MISSING_ARGS,
368
"'name' parameter is missing.");
369
return NULL;
370
}
371
zPageName = cson_string_cstr(cson_value_get_string(nameV));
372
if(!zPageName || !*zPageName){
373
json_set_err(FSL_JSON_E_INVALID_ARGS,
374
"'name' parameter must be a non-empty string.");
375
return NULL;
376
}
377
rid = db_int(0,
378
"SELECT x.rid FROM tag t, tagxref x"
379
" WHERE x.tagid=t.tagid AND t.tagname='wiki-%q'"
380
" ORDER BY x.mtime DESC LIMIT 1",
381
zPageName
382
);
383
384
if(rid){
385
if(createMode){
386
json_set_err(FSL_JSON_E_RESOURCE_ALREADY_EXISTS,
387
"Wiki page '%s' already exists.",
388
zPageName);
389
goto error;
390
}
391
}else if(!createMode && !allowCreateIfNotExists){
392
json_set_err(FSL_JSON_E_RESOURCE_NOT_FOUND,
393
"Wiki page '%s' not found.",
394
zPageName);
395
goto error;
396
}
397
398
contentV = json_req_payload_get("content");
399
if( !contentV ){
400
if( createMode || (!rid && allowCreateIfNotExists) ){
401
contentV = emptyContent = cson_value_new_string("",0);
402
}else{
403
json_set_err(FSL_JSON_E_MISSING_ARGS,
404
"'content' parameter is missing.");
405
goto error;
406
}
407
}
408
if( !cson_value_is_string(nameV)
409
|| !cson_value_is_string(contentV)){
410
json_set_err(FSL_JSON_E_INVALID_ARGS,
411
"'content' parameter must be a string.");
412
goto error;
413
}
414
jstr = cson_value_get_string(contentV);
415
contentLen = (int)cson_string_length_bytes(jstr);
416
if(contentLen){
417
blob_append(&content, cson_string_cstr(jstr),contentLen);
418
}
419
420
zMimeType = json_find_option_cstr("mimetype","mimetype","M");
421
zMimeType = wiki_filter_mimetypes(zMimeType);
422
423
wiki_cmd_commit(zPageName, rid, &content, zMimeType, 0);
424
blob_reset(&content);
425
/*
426
Our return value here has a race condition: if this operation
427
is called concurrently for the same wiki page via two requests,
428
payV could reflect the results of the other save operation.
429
*/
430
payV = json_get_wiki_page_by_name(
431
cson_string_cstr(
432
cson_value_get_string(nameV)),
433
0);
434
goto ok;
435
error:
436
assert( 0 != g.json.resultCode );
437
assert( NULL == payV );
438
ok:
439
if( emptyContent ){
440
/* We have some potentially tricky memory ownership
441
here, which is why we handle emptyContent separately.
442
443
This is, in fact, overkill because cson_value_new_string("",0)
444
actually returns a shared singleton instance (i.e. doesn't
445
allocate), but that is a cson implementation detail which i
446
don't want leaking into this code...
447
*/
448
cson_value_free(emptyContent);
449
}
450
return payV;
451
452
}
453
454
/*
455
** Implementation of /json/wiki/create.
456
*/
457
static cson_value * json_wiki_create(void){
458
return json_wiki_create_or_save(1,0);
459
}
460
461
/*
462
** Implementation of /json/wiki/save.
463
*/
464
static cson_value * json_wiki_save(void){
465
char const createIfNotExists = json_getenv_bool("createIfNotExists",0);
466
return json_wiki_create_or_save(0,createIfNotExists);
467
}
468
469
/*
470
** Implementation of /json/wiki/list.
471
*/
472
static cson_value * json_wiki_list(void){
473
cson_value * listV = NULL;
474
cson_array * list = NULL;
475
char const * zGlob = NULL;
476
Stmt q = empty_Stmt;
477
Blob sql = empty_blob;
478
char const verbose = json_find_option_bool("verbose",NULL,"v",0);
479
char fInvert = json_find_option_bool("invert",NULL,"i",0);;
480
481
if( !g.perm.RdWiki && !g.perm.Read ){
482
json_set_err(FSL_JSON_E_DENIED,
483
"Requires 'j' or 'o' permissions.");
484
return NULL;
485
}
486
blob_append(&sql,"SELECT"
487
" DISTINCT substr(tagname,6) as name"
488
" FROM tag JOIN tagxref USING('tagid')"
489
" WHERE tagname GLOB 'wiki-*'"
490
" AND TYPEOF(tagxref.value+0)='integer'",
491
/* ^^^ elide wiki- tags which are not wiki pages */
492
-1);
493
zGlob = json_find_option_cstr("glob",NULL,"g");
494
if(zGlob && *zGlob){
495
blob_append_sql(&sql," AND name %s GLOB %Q",
496
fInvert ? "NOT" : "", zGlob);
497
}else{
498
zGlob = json_find_option_cstr("like",NULL,"l");
499
if(zGlob && *zGlob){
500
blob_append_sql(&sql," AND name %s LIKE %Q",
501
fInvert ? "NOT" : "", zGlob);
502
}
503
}
504
blob_append(&sql," ORDER BY lower(name)", -1);
505
db_prepare(&q,"%s", blob_sql_text(&sql));
506
blob_reset(&sql);
507
listV = cson_value_new_array();
508
list = cson_value_get_array(listV);
509
while( SQLITE_ROW == db_step(&q) ){
510
cson_value * v;
511
if( verbose ){
512
char const * name = db_column_text(&q,0);
513
v = json_get_wiki_page_by_name(name,0);
514
}else{
515
v = cson_sqlite3_column_to_value(q.pStmt,0);
516
}
517
if(!v){
518
json_set_err(FSL_JSON_E_UNKNOWN,
519
"Could not convert wiki name column to JSON.");
520
goto error;
521
}else if( 0 != cson_array_append( list, v ) ){
522
cson_value_free(v);
523
json_set_err(FSL_JSON_E_ALLOC,"Could not append wiki page name to array.")
524
/* OOM (or maybe numeric overflow) are the only realistic
525
error codes for that particular failure.*/;
526
goto error;
527
}
528
}
529
goto end;
530
error:
531
assert(0 != g.json.resultCode);
532
cson_value_free(listV);
533
listV = NULL;
534
end:
535
db_finalize(&q);
536
return listV;
537
}
538
539
static cson_value * json_wiki_diff(void){
540
char const * zV1 = NULL;
541
char const * zV2 = NULL;
542
cson_object * pay = NULL;
543
int argPos = g.json.dispatchDepth;
544
int r1 = 0, r2 = 0;
545
Manifest * pW1 = NULL, *pW2 = NULL;
546
Blob w1 = empty_blob, w2 = empty_blob, d = empty_blob;
547
char const * zErrTag = NULL;
548
DiffConfig DCfg;
549
char * zUuid = NULL;
550
if( !g.perm.Hyperlink ){
551
json_set_err(FSL_JSON_E_DENIED,
552
"Requires 'h' permissions.");
553
return NULL;
554
}
555
556
557
zV1 = json_find_option_cstr2( "v1",NULL, NULL, ++argPos );
558
zV2 = json_find_option_cstr2( "v2",NULL, NULL, ++argPos );
559
if(!zV1 || !*zV1 || !zV2 || !*zV2) {
560
json_set_err(FSL_JSON_E_INVALID_ARGS,
561
"Requires both 'v1' and 'v2' arguments.");
562
return NULL;
563
}
564
565
r1 = symbolic_name_to_rid( zV1, "w" );
566
zErrTag = zV1;
567
if(r1<0){
568
goto ambiguous;
569
}else if(0==r1){
570
goto invalid;
571
}
572
573
r2 = symbolic_name_to_rid( zV2, "w" );
574
zErrTag = zV2;
575
if(r2<0){
576
goto ambiguous;
577
}else if(0==r2){
578
goto invalid;
579
}
580
581
zErrTag = zV1;
582
pW1 = manifest_get(r1, CFTYPE_WIKI, 0);
583
if( pW1==0 ) {
584
goto manifest;
585
}
586
zErrTag = zV2;
587
pW2 = manifest_get(r2, CFTYPE_WIKI, 0);
588
if( pW2==0 ) {
589
goto manifest;
590
}
591
592
blob_init(&w1, pW1->zWiki, -1);
593
blob_zero(&w2);
594
blob_init(&w2, pW2->zWiki, -1);
595
blob_zero(&d);
596
diff_config_init(&DCfg, DIFF_IGNORE_EOLWS | DIFF_STRIP_EOLCR);
597
text_diff(&w1, &w2, &d, &DCfg);
598
blob_reset(&w1);
599
blob_reset(&w2);
600
601
pay = cson_new_object();
602
603
zUuid = json_wiki_get_uuid_for_rid( pW1->rid );
604
cson_object_set(pay, "v1", json_new_string(zUuid) );
605
free(zUuid);
606
zUuid = json_wiki_get_uuid_for_rid( pW2->rid );
607
cson_object_set(pay, "v2", json_new_string(zUuid) );
608
free(zUuid);
609
zUuid = NULL;
610
611
manifest_destroy(pW1);
612
manifest_destroy(pW2);
613
614
cson_object_set(pay, "diff",
615
cson_value_new_string( blob_str(&d),
616
(unsigned int)blob_size(&d)));
617
618
return cson_object_value(pay);
619
620
manifest:
621
json_set_err(FSL_JSON_E_UNKNOWN,
622
"Could not load wiki manifest for UUID [%s].",
623
zErrTag);
624
goto end;
625
626
ambiguous:
627
json_set_err(FSL_JSON_E_AMBIGUOUS_UUID,
628
"UUID [%s] is ambiguous.", zErrTag);
629
goto end;
630
631
invalid:
632
json_set_err(FSL_JSON_E_RESOURCE_NOT_FOUND,
633
"UUID [%s] not found.", zErrTag);
634
goto end;
635
636
end:
637
cson_free_object(pay);
638
return NULL;
639
}
640
641
#endif /* FOSSIL_ENABLE_JSON */
642

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button