Fossil SCM

Have /forumnew stash edits in local/sessionStorage and restore its state if it's reloaded. It clears the stash on a successful submit (which is still TBD). Process pikchrs in the preview.

stephan 2026-06-06 14:34 UTC forum-editor-2026
Commit adcbe4496ba86172d5139ac4fc051bea932f3b09748b24d69c2b43256e79a6a7
+1 -1
--- src/forum.c
+++ src/forum.c
@@ -1483,11 +1483,11 @@
14831483
** to all forum-related pages. It does not include page-specific
14841484
** code (e.g. "forum.js").
14851485
*/
14861486
static void forum_emit_js(void){
14871487
builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer",
1488
- "attach", "tabs", NULL);
1488
+ "attach", "tabs", "storage", NULL);
14891489
builtin_request_js("fossil.page.forumpost.js");
14901490
}
14911491
14921492
/*
14931493
** WEBPAGE: forumpost
14941494
--- src/forum.c
+++ src/forum.c
@@ -1483,11 +1483,11 @@
1483 ** to all forum-related pages. It does not include page-specific
1484 ** code (e.g. "forum.js").
1485 */
1486 static void forum_emit_js(void){
1487 builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer",
1488 "attach", "tabs", NULL);
1489 builtin_request_js("fossil.page.forumpost.js");
1490 }
1491
1492 /*
1493 ** WEBPAGE: forumpost
1494
--- src/forum.c
+++ src/forum.c
@@ -1483,11 +1483,11 @@
1483 ** to all forum-related pages. It does not include page-specific
1484 ** code (e.g. "forum.js").
1485 */
1486 static void forum_emit_js(void){
1487 builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer",
1488 "attach", "tabs", "storage", NULL);
1489 builtin_request_js("fossil.page.forumpost.js");
1490 }
1491
1492 /*
1493 ** WEBPAGE: forumpost
1494
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -1,8 +1,8 @@
11
/**
22
Code for the forum family of pages. Requires fossil.X where X is
3
- (copybutton, pikchr, confirmer, attach, tabs).
3
+ (copybutton, pikchr, confirmer, attach, tabs, storage).
44
*/
55
(function(F/*the fossil object*/){
66
"use strict";
77
/* JS code for /forumpost and friends. Requires fossil.dom
88
and can optionally use fossil.pikchr. */
@@ -30,18 +30,26 @@
3030
/* Extra input[type=hidden] fields imported from fossil's
3131
static page generation. */
3232
#extraFields;
3333
3434
/**
35
- */
35
+ Options:
36
+
37
+ opt.draftKey[string=undefined]: if set then this object's state
38
+ will be stored in fossil.storage when the relevant input fields
39
+ lose focus. If old state is found, the form is pre-populated
40
+ from it. The state is cleared on a successful submit.
41
+ */
3642
constructor(opt){
3743
opt = this.#opt = F.nu({
3844
// todo: defaults once we determine the options
3945
// replyTo: hash
4046
// edit: hash
47
+ draftKey: undefined
4148
}, opt);
4249
opt.isNewThread = !opt.replyTo && !opt.edit;
50
+ if( !opt.draftKey) opt.draftKey = '';
4351
const e = this.#e = F.nu({
4452
mimetype: F.nu(),
4553
button: F.nu()
4654
});
4755
const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
@@ -52,10 +60,17 @@
5260
e.title.setAttribute('maxlength', 125);
5361
e.titleBar.append(
5462
D.append(D.span(), "Title:"),
5563
e.title
5664
);
65
+ if( opt.draftKey ){
66
+ const key = opt.draftKey+'.title';
67
+ e.title.addEventListener('blur', ()=>{
68
+ F.storage.set(key, e.title.value)
69
+ });
70
+ e.title.value = F.storage.get(key,'');
71
+ }
5772
wrapper.append(e.titleBar);
5873
}
5974
e.mimetype.wrapper = D.addClass(D.div(), 'mimetype-wrapper');
6075
e.mimetype.select = D.addClass(D.select(), 'mimetype-select');
6176
this.#toDisable.push(e.mimetype.select);
@@ -92,11 +107,11 @@
92107
e.err = D.addClass(D.div(), 'error', 'hidden');
93108
wrapper.append(e.err);
94109
e.err.addEventListener('dblclick',()=>this.reportError());
95110
96111
const idPrefix = 'FormPostEditor'+(++idCounter)/* TabManager requires IDs */;
97
- { /* Tabs... */
112
+ { /* Main tabs... */
98113
e.tabs = D.attr(
99114
D.addClass(D.div(), 'tab-container'),
100115
'id', idPrefix+'-tabs'
101116
);
102117
this.#tabs = new F.TabManager(e.tabs);
@@ -117,11 +132,17 @@
117132
);
118133
e.tabEdit.append(e.editor);
119134
e.tabEdit.dataset.tabLabel = 'Edit';
120135
this.#tabs.addTab( e.tabEdit );
121136
this.#tabs.switchToTab( e.tabEdit );
122
-
137
+ if( opt.draftKey ){
138
+ const key = opt.draftKey+'.content';
139
+ this.editorContent = F.storage.get(key,'');
140
+ e.editor.addEventListener(
141
+ 'blur', ()=>F.storage.set(key, this.editorContent)
142
+ );
143
+ }
123144
e.preview = D.addClass(D.div(), 'preview');
124145
e.preview.dataset.tabLabel = 'Preview';
125146
this.#tabs.addTab( e.preview );
126147
}
127148
@@ -154,15 +175,43 @@
154175
/* Reminder: we don't currently have a way to disable/enable
155176
an Attacher's controls. */
156177
}
157178
e.buttons.append(e.button.preview, e.button.submit);
158179
this.#toDisable.push(e.button.preview);
180
+
181
+ if( opt.hiddenFields ){
182
+ this.addHiddenFields( opt.hiddenFields );
183
+ delete opt.hiddenFields;
184
+ }
185
+
159186
}/*constructor*/
160187
188
+ /** This widget's top-most DOM element. */
161189
get widget(){
162190
return this.#e.widget;
163191
}
192
+
193
+ get editorContent(){
194
+ /* We wrap access to the editor's contents in a getter/setter so
195
+ that we can eventually add optional use of a contenteditable
196
+ edit field, as those are generally more comfortable. The code
197
+ for that is in fossil.page.chat.js. */
198
+ return this.#e.editor.value;
199
+ }
200
+
201
+ set editorContent(v){
202
+ this.#e.editor.value = v;
203
+ }
204
+
205
+ /** Clears any draft state. */
206
+ clearDraft(){
207
+ const k = this.#opt.draftKey;
208
+ if( k ){
209
+ F.storage.remove(k+'.content');
210
+ F.storage.remove(k+'.title');
211
+ }
212
+ }
164213
165214
/**
166215
Reports an error by appending each argument to the error widget
167216
and unhiding it. If passed no arugments, it clears and hides
168217
the error widget.
@@ -185,25 +234,31 @@
185234
*/
186235
addHiddenFields(list){
187236
this.#extraFields ??= [];
188237
for( const f of list ){
189238
if( 'title'===f.name && this.#opt.isNewThread ){
190
- this.#e.title.value = f.value;
239
+ if( !this.#e.title.value ){
240
+ this.#e.title.value = f.value;
241
+ }
191242
}else{
192243
this.#extraFields.push(f);
193244
}
194245
}
195246
}
247
+
248
+ get mimetype(){
249
+ return e.mimetype.select.value;
250
+ }
196251
197252
async #fetchPreview(content){
198253
/* TODO: fetch preview */
199254
const e = this.#e;
200255
const fd = new FormData;
201256
for(const f of this.#extraFields){
202257
fd.append(f.name, f.value);
203258
}
204
- fd.append('mimetype', e.mimetype.select.value);
259
+ fd.append('mimetype', this.mimetype);
205260
fd.append('content', content);
206261
return window
207262
.fetch(F.repoUrl('wikiajax/preview'), {
208263
method: 'POST',
209264
body: fd
@@ -215,10 +270,20 @@
215270
throw new Error(o.error);
216271
}
217272
return t;
218273
});
219274
}
275
+
276
+ setContent(rawHtml){
277
+ const preview = this.#e.preview;
278
+ previe.innerHTML = c;
279
+ if(F.pikchr && 'text/x-markdown'===this.mimetype){
280
+ F.pikchr.addSrcView(
281
+ preview.querySelectorAll('svg.pikchr')
282
+ );
283
+ }
284
+ }
220285
221286
async #preview(){
222287
if( this.#isWaiting ) return;
223288
const e = this.#e;
224289
if( e.preview !== this.#activeTab ){
@@ -226,19 +291,19 @@
226291
/* Will recurse into here */
227292
return;
228293
}
229294
this.#isWaiting = true;
230295
D.clearElement(e.preview);
231
- const content = e.editor.value.trim();
296
+ const content = this.editorContent.trim();
232297
if( !content ){
233298
return;
234299
}
235300
D.disable(this.#toDisable, e.button.submit);
236301
e.preview.textContent = "Fetching preview...";
237302
this.#fetchPreview(content)
238303
.then((c)=>{
239
- e.preview.innerHTML = c;
304
+ this.setContent(c);
240305
D.enable(this.#toDisable, e.button.submit);
241306
})
242307
.catch(err=>{
243308
e.preview.textContent = "Error fetching preview: "+err.message;
244309
this.reportError(err.message);
@@ -264,10 +329,14 @@
264329
if( !this.#validate() ) return;
265330
this.#isWaiting = true;
266331
const e = this.#e;
267332
D.disable(e.button.submit);
268333
this.reportError("Submit is TODO.");
334
+ if( opt.draftKey ){
335
+ F.storage.remove(opt.draftKey+'.content');
336
+ F.storage.remove(opt.draftKey+'.title');
337
+ }
269338
/*
270339
TODO: save it, set #isWaiting=false, then handle error or
271340
redirect to the post (if this is a new post) or, if replying
272341
inline, replace this object with a static rendering from the
273342
response.
@@ -463,13 +532,15 @@
463532
const eForumNew = document.body.classList.contains('cpage-forumnew')
464533
? document.querySelector('#forumnew-placeholder')
465534
: null;
466535
if( eForumNew ){
467536
/* /forumnew */
468
- const fpe = new fossil.ForumPostEditor({});
469
- fpe.addHiddenFields( eForumNew.querySelectorAll('input[type=hidden]') );
537
+ const fpe = new fossil.ForumPostEditor({
538
+ draftKey: 'forumnew',
539
+ hiddenFields: eForumNew.querySelectorAll('input[type=hidden]')
540
+ });
470541
eForumNew.parentElement.insertBefore(fpe.widget, eForumNew);
471542
eForumNew.remove();
472543
fossil.page.fpe = fpe /* for testing via the console */;
473544
}/*eForumNew*/
474545
})/*F.onPageLoad callback*/;
475546
})(window.fossil);
476547
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -1,8 +1,8 @@
1 /**
2 Code for the forum family of pages. Requires fossil.X where X is
3 (copybutton, pikchr, confirmer, attach, tabs).
4 */
5 (function(F/*the fossil object*/){
6 "use strict";
7 /* JS code for /forumpost and friends. Requires fossil.dom
8 and can optionally use fossil.pikchr. */
@@ -30,18 +30,26 @@
30 /* Extra input[type=hidden] fields imported from fossil's
31 static page generation. */
32 #extraFields;
33
34 /**
35 */
 
 
 
 
 
 
36 constructor(opt){
37 opt = this.#opt = F.nu({
38 // todo: defaults once we determine the options
39 // replyTo: hash
40 // edit: hash
 
41 }, opt);
42 opt.isNewThread = !opt.replyTo && !opt.edit;
 
43 const e = this.#e = F.nu({
44 mimetype: F.nu(),
45 button: F.nu()
46 });
47 const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
@@ -52,10 +60,17 @@
52 e.title.setAttribute('maxlength', 125);
53 e.titleBar.append(
54 D.append(D.span(), "Title:"),
55 e.title
56 );
 
 
 
 
 
 
 
57 wrapper.append(e.titleBar);
58 }
59 e.mimetype.wrapper = D.addClass(D.div(), 'mimetype-wrapper');
60 e.mimetype.select = D.addClass(D.select(), 'mimetype-select');
61 this.#toDisable.push(e.mimetype.select);
@@ -92,11 +107,11 @@
92 e.err = D.addClass(D.div(), 'error', 'hidden');
93 wrapper.append(e.err);
94 e.err.addEventListener('dblclick',()=>this.reportError());
95
96 const idPrefix = 'FormPostEditor'+(++idCounter)/* TabManager requires IDs */;
97 { /* Tabs... */
98 e.tabs = D.attr(
99 D.addClass(D.div(), 'tab-container'),
100 'id', idPrefix+'-tabs'
101 );
102 this.#tabs = new F.TabManager(e.tabs);
@@ -117,11 +132,17 @@
117 );
118 e.tabEdit.append(e.editor);
119 e.tabEdit.dataset.tabLabel = 'Edit';
120 this.#tabs.addTab( e.tabEdit );
121 this.#tabs.switchToTab( e.tabEdit );
122
 
 
 
 
 
 
123 e.preview = D.addClass(D.div(), 'preview');
124 e.preview.dataset.tabLabel = 'Preview';
125 this.#tabs.addTab( e.preview );
126 }
127
@@ -154,15 +175,43 @@
154 /* Reminder: we don't currently have a way to disable/enable
155 an Attacher's controls. */
156 }
157 e.buttons.append(e.button.preview, e.button.submit);
158 this.#toDisable.push(e.button.preview);
 
 
 
 
 
 
159 }/*constructor*/
160
 
161 get widget(){
162 return this.#e.widget;
163 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
165 /**
166 Reports an error by appending each argument to the error widget
167 and unhiding it. If passed no arugments, it clears and hides
168 the error widget.
@@ -185,25 +234,31 @@
185 */
186 addHiddenFields(list){
187 this.#extraFields ??= [];
188 for( const f of list ){
189 if( 'title'===f.name && this.#opt.isNewThread ){
190 this.#e.title.value = f.value;
 
 
191 }else{
192 this.#extraFields.push(f);
193 }
194 }
195 }
 
 
 
 
196
197 async #fetchPreview(content){
198 /* TODO: fetch preview */
199 const e = this.#e;
200 const fd = new FormData;
201 for(const f of this.#extraFields){
202 fd.append(f.name, f.value);
203 }
204 fd.append('mimetype', e.mimetype.select.value);
205 fd.append('content', content);
206 return window
207 .fetch(F.repoUrl('wikiajax/preview'), {
208 method: 'POST',
209 body: fd
@@ -215,10 +270,20 @@
215 throw new Error(o.error);
216 }
217 return t;
218 });
219 }
 
 
 
 
 
 
 
 
 
 
220
221 async #preview(){
222 if( this.#isWaiting ) return;
223 const e = this.#e;
224 if( e.preview !== this.#activeTab ){
@@ -226,19 +291,19 @@
226 /* Will recurse into here */
227 return;
228 }
229 this.#isWaiting = true;
230 D.clearElement(e.preview);
231 const content = e.editor.value.trim();
232 if( !content ){
233 return;
234 }
235 D.disable(this.#toDisable, e.button.submit);
236 e.preview.textContent = "Fetching preview...";
237 this.#fetchPreview(content)
238 .then((c)=>{
239 e.preview.innerHTML = c;
240 D.enable(this.#toDisable, e.button.submit);
241 })
242 .catch(err=>{
243 e.preview.textContent = "Error fetching preview: "+err.message;
244 this.reportError(err.message);
@@ -264,10 +329,14 @@
264 if( !this.#validate() ) return;
265 this.#isWaiting = true;
266 const e = this.#e;
267 D.disable(e.button.submit);
268 this.reportError("Submit is TODO.");
 
 
 
 
269 /*
270 TODO: save it, set #isWaiting=false, then handle error or
271 redirect to the post (if this is a new post) or, if replying
272 inline, replace this object with a static rendering from the
273 response.
@@ -463,13 +532,15 @@
463 const eForumNew = document.body.classList.contains('cpage-forumnew')
464 ? document.querySelector('#forumnew-placeholder')
465 : null;
466 if( eForumNew ){
467 /* /forumnew */
468 const fpe = new fossil.ForumPostEditor({});
469 fpe.addHiddenFields( eForumNew.querySelectorAll('input[type=hidden]') );
 
 
470 eForumNew.parentElement.insertBefore(fpe.widget, eForumNew);
471 eForumNew.remove();
472 fossil.page.fpe = fpe /* for testing via the console */;
473 }/*eForumNew*/
474 })/*F.onPageLoad callback*/;
475 })(window.fossil);
476
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -1,8 +1,8 @@
1 /**
2 Code for the forum family of pages. Requires fossil.X where X is
3 (copybutton, pikchr, confirmer, attach, tabs, storage).
4 */
5 (function(F/*the fossil object*/){
6 "use strict";
7 /* JS code for /forumpost and friends. Requires fossil.dom
8 and can optionally use fossil.pikchr. */
@@ -30,18 +30,26 @@
30 /* Extra input[type=hidden] fields imported from fossil's
31 static page generation. */
32 #extraFields;
33
34 /**
35 Options:
36
37 opt.draftKey[string=undefined]: if set then this object's state
38 will be stored in fossil.storage when the relevant input fields
39 lose focus. If old state is found, the form is pre-populated
40 from it. The state is cleared on a successful submit.
41 */
42 constructor(opt){
43 opt = this.#opt = F.nu({
44 // todo: defaults once we determine the options
45 // replyTo: hash
46 // edit: hash
47 draftKey: undefined
48 }, opt);
49 opt.isNewThread = !opt.replyTo && !opt.edit;
50 if( !opt.draftKey) opt.draftKey = '';
51 const e = this.#e = F.nu({
52 mimetype: F.nu(),
53 button: F.nu()
54 });
55 const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
@@ -52,10 +60,17 @@
60 e.title.setAttribute('maxlength', 125);
61 e.titleBar.append(
62 D.append(D.span(), "Title:"),
63 e.title
64 );
65 if( opt.draftKey ){
66 const key = opt.draftKey+'.title';
67 e.title.addEventListener('blur', ()=>{
68 F.storage.set(key, e.title.value)
69 });
70 e.title.value = F.storage.get(key,'');
71 }
72 wrapper.append(e.titleBar);
73 }
74 e.mimetype.wrapper = D.addClass(D.div(), 'mimetype-wrapper');
75 e.mimetype.select = D.addClass(D.select(), 'mimetype-select');
76 this.#toDisable.push(e.mimetype.select);
@@ -92,11 +107,11 @@
107 e.err = D.addClass(D.div(), 'error', 'hidden');
108 wrapper.append(e.err);
109 e.err.addEventListener('dblclick',()=>this.reportError());
110
111 const idPrefix = 'FormPostEditor'+(++idCounter)/* TabManager requires IDs */;
112 { /* Main tabs... */
113 e.tabs = D.attr(
114 D.addClass(D.div(), 'tab-container'),
115 'id', idPrefix+'-tabs'
116 );
117 this.#tabs = new F.TabManager(e.tabs);
@@ -117,11 +132,17 @@
132 );
133 e.tabEdit.append(e.editor);
134 e.tabEdit.dataset.tabLabel = 'Edit';
135 this.#tabs.addTab( e.tabEdit );
136 this.#tabs.switchToTab( e.tabEdit );
137 if( opt.draftKey ){
138 const key = opt.draftKey+'.content';
139 this.editorContent = F.storage.get(key,'');
140 e.editor.addEventListener(
141 'blur', ()=>F.storage.set(key, this.editorContent)
142 );
143 }
144 e.preview = D.addClass(D.div(), 'preview');
145 e.preview.dataset.tabLabel = 'Preview';
146 this.#tabs.addTab( e.preview );
147 }
148
@@ -154,15 +175,43 @@
175 /* Reminder: we don't currently have a way to disable/enable
176 an Attacher's controls. */
177 }
178 e.buttons.append(e.button.preview, e.button.submit);
179 this.#toDisable.push(e.button.preview);
180
181 if( opt.hiddenFields ){
182 this.addHiddenFields( opt.hiddenFields );
183 delete opt.hiddenFields;
184 }
185
186 }/*constructor*/
187
188 /** This widget's top-most DOM element. */
189 get widget(){
190 return this.#e.widget;
191 }
192
193 get editorContent(){
194 /* We wrap access to the editor's contents in a getter/setter so
195 that we can eventually add optional use of a contenteditable
196 edit field, as those are generally more comfortable. The code
197 for that is in fossil.page.chat.js. */
198 return this.#e.editor.value;
199 }
200
201 set editorContent(v){
202 this.#e.editor.value = v;
203 }
204
205 /** Clears any draft state. */
206 clearDraft(){
207 const k = this.#opt.draftKey;
208 if( k ){
209 F.storage.remove(k+'.content');
210 F.storage.remove(k+'.title');
211 }
212 }
213
214 /**
215 Reports an error by appending each argument to the error widget
216 and unhiding it. If passed no arugments, it clears and hides
217 the error widget.
@@ -185,25 +234,31 @@
234 */
235 addHiddenFields(list){
236 this.#extraFields ??= [];
237 for( const f of list ){
238 if( 'title'===f.name && this.#opt.isNewThread ){
239 if( !this.#e.title.value ){
240 this.#e.title.value = f.value;
241 }
242 }else{
243 this.#extraFields.push(f);
244 }
245 }
246 }
247
248 get mimetype(){
249 return e.mimetype.select.value;
250 }
251
252 async #fetchPreview(content){
253 /* TODO: fetch preview */
254 const e = this.#e;
255 const fd = new FormData;
256 for(const f of this.#extraFields){
257 fd.append(f.name, f.value);
258 }
259 fd.append('mimetype', this.mimetype);
260 fd.append('content', content);
261 return window
262 .fetch(F.repoUrl('wikiajax/preview'), {
263 method: 'POST',
264 body: fd
@@ -215,10 +270,20 @@
270 throw new Error(o.error);
271 }
272 return t;
273 });
274 }
275
276 setContent(rawHtml){
277 const preview = this.#e.preview;
278 previe.innerHTML = c;
279 if(F.pikchr && 'text/x-markdown'===this.mimetype){
280 F.pikchr.addSrcView(
281 preview.querySelectorAll('svg.pikchr')
282 );
283 }
284 }
285
286 async #preview(){
287 if( this.#isWaiting ) return;
288 const e = this.#e;
289 if( e.preview !== this.#activeTab ){
@@ -226,19 +291,19 @@
291 /* Will recurse into here */
292 return;
293 }
294 this.#isWaiting = true;
295 D.clearElement(e.preview);
296 const content = this.editorContent.trim();
297 if( !content ){
298 return;
299 }
300 D.disable(this.#toDisable, e.button.submit);
301 e.preview.textContent = "Fetching preview...";
302 this.#fetchPreview(content)
303 .then((c)=>{
304 this.setContent(c);
305 D.enable(this.#toDisable, e.button.submit);
306 })
307 .catch(err=>{
308 e.preview.textContent = "Error fetching preview: "+err.message;
309 this.reportError(err.message);
@@ -264,10 +329,14 @@
329 if( !this.#validate() ) return;
330 this.#isWaiting = true;
331 const e = this.#e;
332 D.disable(e.button.submit);
333 this.reportError("Submit is TODO.");
334 if( opt.draftKey ){
335 F.storage.remove(opt.draftKey+'.content');
336 F.storage.remove(opt.draftKey+'.title');
337 }
338 /*
339 TODO: save it, set #isWaiting=false, then handle error or
340 redirect to the post (if this is a new post) or, if replying
341 inline, replace this object with a static rendering from the
342 response.
@@ -463,13 +532,15 @@
532 const eForumNew = document.body.classList.contains('cpage-forumnew')
533 ? document.querySelector('#forumnew-placeholder')
534 : null;
535 if( eForumNew ){
536 /* /forumnew */
537 const fpe = new fossil.ForumPostEditor({
538 draftKey: 'forumnew',
539 hiddenFields: eForumNew.querySelectorAll('input[type=hidden]')
540 });
541 eForumNew.parentElement.insertBefore(fpe.widget, eForumNew);
542 eForumNew.remove();
543 fossil.page.fpe = fpe /* for testing via the console */;
544 }/*eForumNew*/
545 })/*F.onPageLoad callback*/;
546 })(window.fossil);
547

Keyboard Shortcuts

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