Only files matching the fileedit-glob
"
@@ -1949,28 +1958,26 @@
blob_appendf(&endScript,");\n");
}
blob_reset(&err);
CheckinMiniInfo_cleanup(&cimi);
- style_emit_script_fossil_bootstrap(0);
- append_diff_javascript(1);
- builtin_request_js("fossil.fetch.js");
- builtin_request_js("fossil.dom.js");
- builtin_request_js("fossil.tabs.js");
- builtin_request_js("fossil.confirmer.js");
- builtin_request_js("fossil.storage.js");
-
+
+ builtin_request_js("sbsdiff.js");
+ style_emit_fossil_js_apis(0, "fetch", "dom", "tabs", "confirmer",
+ "storage", 0);
+ builtin_fulfill_js_requests();
/*
** Set up a JS-side mapping of the AJAX_RENDER_xyz values. This is
** used for dynamically toggling certain UI components on and off.
- ** Must come before fossil.page.fileedit.js and after the fetches
- ** above.
+ ** Must come after window.fossil has been intialized and before
+ ** fossil.page.fileedit.js. Potential TODO: move this into the
+ ** window.fossil bootstrapping so that we don't have to "fulfill"
+ ** the JS multiple times.
*/
- builtin_fulfill_js_requests();
ajax_emit_js_preview_modes(1);
-
builtin_request_js("fossil.page.fileedit.js");
+ builtin_fulfill_js_requests();
if(blob_size(&endScript)>0){
style_emit_script_tag(0,0);
CX("\n(function(){\n");
CX("try{\n%b}\n"
"catch(e){"
Index: src/forum.c
==================================================================
--- src/forum.c
+++ src/forum.c
@@ -749,22 +749,18 @@
forumthread_delete(pThread);
return target;
}
/*
-** The first time this is called, it emits SCRIPT tags to load various
-** forum-related JavaScript. Ideally it should be called near the end
-** of the page, immediately before the call to style_footer() (which
-** closes the document's and tags). Calls after the first
-** are a no-op.
+** Emits all JS code required by /forumpost.
*/
-static void forum_emit_page_js(){
+static void forumpost_emit_page_js(){
static int once = 0;
if(0==once){
once = 1;
+ style_emit_script_fossil_bootstrap(1);
builtin_request_js("forum.js");
- style_emit_script_fossil_bootstrap(0);
builtin_request_js("fossil.dom.js");
builtin_request_js("fossil.page.forumpost.js");
}
}
@@ -894,11 +890,11 @@
}else{
style_submenu_element("Chronological", "%R/%s/%s?t=c", g.zPath, zName);
style_submenu_element("Unformatted", "%R/%s/%s?t=r", g.zPath, zName);
forum_display_hierarchical(froot, fpid);
}
- forum_emit_page_js();
+ forumpost_emit_page_js();
style_footer();
}
/*
** Return true if a forum post should be moderated.
Index: src/forum.js
==================================================================
--- src/forum.js
+++ src/forum.js
@@ -14,6 +14,6 @@
var h = x[0].scrollHeight;
var y = absoluteY(x[0]);
if( w>h ) y = y + (h-w)/2;
if( y>0 ) window.scrollTo(0, y);
}
-})()
+})();
Index: src/fossil.page.fileedit.js
==================================================================
--- src/fossil.page.fileedit.js
+++ src/fossil.page.fileedit.js
@@ -121,11 +121,11 @@
and that would be horribly inefficient (meaning "battery-consuming"
on mobile devices).
*/
const $stash = {
keys: {
- index: F.page.name+'/index'
+ index: F.page.name+'.index'
},
/**
index: {
"CHECKIN_HASH:FILENAME": {file info w/o content}
...
@@ -149,27 +149,12 @@
/** Returns the index object, fetching it from the stash or creating
it anew on the first call. */
getIndex: function(){
if(!this.index){
this.index = F.storage.getJSON(
- this.keys.index, undefined
+ this.keys.index, {}
);
- if(!this.index){
- /*check for and remove/replace older name. This whole block
- can be removed once the test phase is done (don't want to
- invalidate the testers' edits on the test server). When
- doing so, be sure to replace undefined in the above
- getJSON() call with {}. */
- const oldName = F.page.name+':index';
- this.index = F.storage.getJSON(oldName,undefined);
- if(this.index){
- F.storage.remove(oldName);
- this.storeIndex();
- }else{
- this.index = {};
- }
- }
}
return this.index;
},
_fireStashEvent: function(){
if(this._disableNextEvent) delete this._disableNextEvent;
ADDED src/fossil.page.wikiedit.js
Index: src/fossil.page.wikiedit.js
==================================================================
--- /dev/null
+++ src/fossil.page.wikiedit.js
@@ -0,0 +1,1194 @@
+(function(F/*the fossil object*/){
+ "use strict";
+ /**
+ Client-side implementation of the /wikiedit app. Requires that
+ the fossil JS bootstrapping is complete and that several fossil
+ JS APIs have been installed: fossil.fetch, fossil.dom,
+ fossil.tabs, fossil.storage, fossil.confirmer.
+
+ Custom events which can be listened for via
+ fossil.page.addEventListener():
+
+ - Event 'wiki-page-loaded': passes on information when it
+ loads a wiki (whether from the network or its internal local-edit
+ cache), in the form of an "winfo" object:
+
+ {
+ name: string,
+ mimetype: mimetype string,
+ type: "normal" | "tag" | "checkin" | "branch" | "sandbox",
+ version: UUID string or null for a sandbox page or new page,
+ parent: parent UUID string or null if no parent,
+ content: string
+ }
+
+ The internal docs and code frequently use the term "winfo", and such
+ references refer to an object with that form.
+
+ The fossil.page.wikiContent() method gets or sets the current
+ file content for the page.
+
+ - Event 'wiki-saved': is fired when a commit completes,
+ passing on the same info as fileedit-file-loaded.
+
+ - Event 'wiki-content-replaced': when the editor's content is
+ replaced, as opposed to it being edited via user
+ interaction. This normally happens via selecting a file to
+ load. The event detail is the fossil.page object, not the current
+ file content.
+
+ - Event 'wiki-preview-updated': when the preview is refreshed
+ from the server, this event passes on information about the preview
+ change in the form of an object:
+
+ {
+ element: the DOM element which contains the content preview.
+ mimetype: the page's mimetype.
+ }
+
+ Here's an example which can be used with the highlightjs code
+ highlighter to update the highlighting when the preview is
+ refreshed in "wiki" mode (which includes fossil-native wiki and
+ markdown):
+
+ fossil.page.addEventListener(
+ 'wiki-preview-updated',
+ (ev)=>{
+ if(ev.detail.mimetype!=='text/plain'){
+ ev.detail.element.querySelectorAll(
+ 'code[class^=language-]'
+ ).forEach((e)=>hljs.highlightBlock(e));
+ }
+ }
+ );
+ */
+ const E = (s)=>document.querySelector(s),
+ D = F.dom,
+ P = F.page;
+
+ P.config = {
+ /* Symbolic markers to denote certain edit state. Note that
+ the symbols themselves are *actually* defined in CSS, so if
+ they're changed there they also need to be changed here.*/
+ editStateMarkers: {
+ isNew: '[+]',
+ isModified: '[*]'
+ }
+ };
+
+ /**
+ $stash is an internal-use-only object for managing "stashed"
+ local edits, to help avoid that users accidentally lose content
+ by switching tabs or following links or some such. The basic
+ theory of operation is...
+
+ All "stashed" state is stored using fossil.storage.
+
+ - When the current wiki content is modified by the user, the
+ current state of the page is stashed.
+
+ - When saving, the stashed entry for the previous version is
+ removed from the stash.
+
+ - When "loading", we use any stashed state for the given
+ checkin/file combination. When forcing a re-load of content,
+ any stashed entry for that combination is removed from the
+ stash.
+
+ - Every time P.stashContentChange() updates the stash, it is
+ pruned to $stash.prune.defaultMaxCount most-recently-updated
+ entries.
+
+ - This API often refers to "winfo objects." Those are objects
+ with a minimum of {page,mimetype} properties (which must be
+ valid), and the page name is used as basis for the stash keys
+ for any given page.
+
+ The structure of the stash is a bit convoluted for efficiency's
+ sake: we store a map of file info (winfo) objects separately from
+ those files' contents because otherwise we would be required to
+ JSONize/de-JSONize the file content when stashing/restoring it,
+ and that would be horribly inefficient (meaning "battery-consuming"
+ on mobile devices).
+ */
+ const $stash = {
+ keys: {
+ index: F.page.name+'.index'
+ },
+ /**
+ index: {
+ "PAGE_NAME": {wiki page info w/o content}
+ ...
+ }
+
+ In F.storage we...
+
+ - Store this.index under the key this.keys.index.
+
+ - Store each page's content under the key
+ (P.name+'/PAGE_NAME'). These are stored separately from the
+ index entries to avoid having to JSONize/de-JSONize the
+ content. The assumption/hope is that the browser can store
+ those records "directly," without any intermediary
+ encoding/decoding going on.
+ */
+ indexKey: function(winfo){return winfo.name},
+ /** Returns the key for storing content for the given key suffix,
+ by prepending P.name to suffix. */
+ contentKey: function(suffix){return P.name+'/'+suffix},
+ /** Returns the index object, fetching it from the stash or creating
+ it anew on the first call. */
+ getIndex: function(){
+ if(!this.index){
+ this.index = F.storage.getJSON(
+ this.keys.index, {}
+ );
+ }
+ return this.index;
+ },
+ _fireStashEvent: function(){
+ if(this._disableNextEvent) delete this._disableNextEvent;
+ else F.page.dispatchEvent('wiki-stash-updated', this);
+ },
+ /**
+ Returns the stashed version, if any, for the given winfo object.
+ */
+ getWinfo: function(winfo){
+ const ndx = this.getIndex();
+ return ndx[this.indexKey(winfo)];
+ },
+ /** Serializes this object's index to F.storage. Returns this. */
+ storeIndex: function(){
+ if(this.index) F.storage.setJSON(this.keys.index,this.index);
+ return this;
+ },
+ /** Updates the stash record for the given winfo
+ and (optionally) content. If passed 1 arg, only
+ the winfo stash is updated, else both the winfo
+ and its contents are (re-)stashed. Returns this.
+ */
+ updateWinfo: function(winfo,content){
+ const ndx = this.getIndex(),
+ key = this.indexKey(winfo),
+ old = ndx[key];
+ const record = old || (ndx[key]={
+ name: winfo.name
+ });
+ record.mimetype = winfo.mimetype;
+ record.type = winfo.type;
+ record.parent = winfo.parent;
+ record.version = winfo.version;
+ record.stashTime = new Date().getTime();
+ this.storeIndex();
+ if(arguments.length>1){
+ F.storage.set(this.contentKey(key), content);
+ }
+ this._fireStashEvent();
+ return this;
+ },
+ /**
+ Returns the stashed content, if any, for the given winfo
+ object.
+ */
+ stashedContent: function(winfo){
+ return F.storage.get(this.contentKey(this.indexKey(winfo)));
+ },
+ /** Returns true if we have stashed content for the given winfo
+ record or page name. */
+ hasStashedContent: function(winfo){
+ if('string'===typeof winfo) winfo = {name: winfo};
+ return F.storage.contains(this.contentKey(this.indexKey(winfo)));
+ },
+ /** Unstashes the given winfo record and its content.
+ Returns this. */
+ unstash: function(winfo){
+ const ndx = this.getIndex(),
+ key = this.indexKey(winfo);
+ delete winfo.stashTime;
+ delete ndx[key];
+ F.storage.remove(this.contentKey(key));
+ this.storeIndex();
+ this._fireStashEvent();
+ return this;
+ },
+ /**
+ Clears all $stash entries from F.storage. Returns this.
+ */
+ clear: function(){
+ const ndx = this.getIndex(),
+ self = this;
+ let count = 0;
+ Object.keys(ndx).forEach(function(k){
+ ++count;
+ const e = ndx[k];
+ delete ndx[k];
+ F.storage.remove(self.contentKey(k));
+ });
+ F.storage.remove(this.keys.index);
+ delete this.index;
+ if(count) this._fireStashEvent();
+ return this;
+ },
+ /**
+ Removes all but the maxCount most-recently-updated stash
+ entries, where maxCount defaults to this.prune.defaultMaxCount.
+ */
+ prune: function f(maxCount){
+ const ndx = this.getIndex();
+ const li = [];
+ if(!maxCount || maxCount<0) maxCount = f.defaultMaxCount;
+ Object.keys(ndx).forEach((k)=>li.push(ndx[k]));
+ li.sort((l,r)=>l.stashTime - r.stashTime);
+ let n = 0;
+ while(li.length>maxCount){
+ ++n;
+ const e = li.shift();
+ this._disableNextEvent = true;
+ this.unstash(e);
+ console.warn("Pruned oldest local file edit entry:",e);
+ }
+ if(n) this._fireStashEvent();
+ }
+ };
+ $stash.prune.defaultMaxCount = P.config.defaultMaxStashSize || 10;
+ P.$stash = $stash /* we have to expose this for the new-page case :/ */;
+
+ /**
+ Internal workaround to select the current preview mode
+ and fire a change event if the value actually changes
+ or if forceEvent is truthy.
+ */
+ P.selectMimetype = function(modeValue, forceEvent){
+ const s = this.e.selectMimetype;
+ if(!modeValue) modeValue = s.value;
+ else if(s.value != modeValue){
+ s.value = modeValue;
+ forceEvent = true;
+ }
+ if(forceEvent){
+ // Force UI update
+ s.dispatchEvent(new Event('change',{target:s}));
+ }
+ };
+
+ /**
+ Sets up and maintains the widgets for the list of wiki pages.
+ */
+ const WikiList = {
+ e: {
+ filterCheckboxes: {
+ /*map of wiki page type to checkbox for list filtering purposes,
+ except for "sandbox" type, which is assumed to be covered by
+ the "normal" type filter. */},
+ },
+ cache: {
+ names: {
+ /* Map of page names to "something." We don't map to their
+ winfo bits because those regularly get swapped out via
+ de/serialization. We need this map to support the add-new-page
+ feature, to give us a way to check for dupes without asking
+ the server or walking through the whole selection list.
+ */}
+ },
+ /** Updates OPTION elements to reflect whether the page has
+ local changes or is new/unsaved. */
+ refreshStashMarks: function(){
+ this.cache.names = {/*must reset it to acount for local page removals*/};
+ const select = this.e.select, self = this;
+ Object.keys(select.options).forEach(function(key){
+ const opt = select.options[key];
+ const stashed = $stash.getWinfo({name:opt.value});
+ if(stashed){
+ const isNew = 'sandbox'===stashed.type ? false : !stashed.version;
+ D.addClass(opt, isNew ? 'stashed-new' :'stashed');
+ }else{
+ D.removeClass(opt, 'stashed', 'stashed-new');
+ }
+ self.cache.names[opt.value] = true;
+ });
+ },
+ /** Removes the given wiki page entry from the page selection
+ list, if it's in the list. */
+ removeEntry: function(name){
+ const sel = this.e.select;
+ var ndx = sel.selectedIndex;
+ sel.value = name;
+ if(sel.selectedIndex>-1){
+ if(ndx === sel.selectedIndex) ndx = -1;
+ sel.options.remove(sel.selectedIndex);
+ }
+ sel.selectedIndex = ndx;
+ },
+
+ /**
+ Rebuilds the selection list. Necessary when it's loaded from
+ the server or we locally create a new page. */
+ _rebuildList: function callee(){
+ /* Jump through some hoops to integrate new/unsaved
+ pages into the list of existing pages... We use a map
+ as an intermediary in order to filter out any local-stash
+ dupes from server-side copies. */
+ const list = this.cache.pageList;
+ if(!list) return;
+ if(!callee.sorticase){
+ callee.sorticase = function(l,r){
+ if(l===r) return 0;
+ l = l.toLowerCase();
+ r = r.toLowerCase();
+ return l<=r ? -1 : 1;
+ };
+ }
+ const map = {}, ndx = $stash.getIndex(), sel = this.e.select;
+ D.clearElement(sel);
+ list.forEach((winfo)=>map[winfo.name] = winfo);
+ Object.keys(ndx).forEach(function(key){
+ const winfo = ndx[key];
+ if(!winfo.version/*new page*/) map[winfo.name] = winfo;
+ });
+ const self = this;
+ Object.keys(map)
+ .sort(callee.sorticase)
+ .forEach(function(name){
+ const winfo = map[name];
+ const opt = D.option(sel, winfo.name);
+ const wtype = opt.dataset.wtype =
+ winfo.type==='sandbox' ? 'normal' : (winfo.type||'normal');
+ const cb = self.e.filterCheckboxes[wtype];
+ if(cb && !cb.checked) D.addClass(opt, 'hidden');
+ });
+ D.enable(sel);
+ if(P.winfo) sel.value = P.winfo.name;
+ this.refreshStashMarks();
+ },
+
+ /** Loads the page list and populates the selection list. */
+ loadList: function callee(){
+ delete this.pageMap;
+ if(!callee.onload){
+ const self = this;
+ callee.onload = function(list){
+ self.cache.pageList = list;
+ self._rebuildList();
+ F.message("Loaded page list.");
+ };
+ }
+ F.fetch('wikiajax/list',{
+ urlParams:{verbose:true},
+ responseType: 'json',
+ onload: callee.onload
+ });
+ return this;
+ },
+
+ /**
+ Returns true if the given name appears to be a valid
+ wiki page name, noting that the final arbitrator is the
+ server. On validation error it emits a message via fossil.error()
+ and returns false.
+ */
+ validatePageName: function(name){
+ var err;
+ if(!name){
+ err = "may not be empty";
+ }else if(this.cache.names.hasOwnProperty(name)){
+ err = "page already exists: "+name;
+ }else if(name.length>100){
+ err = "too long (limit is 100)";
+ }else if(/\s{2,}/.test(name)){
+ err = "multiple consecutive spaces";
+ }else if(/[\t\r\n]/.test(name)){
+ err = "contains control character(s)";
+ }else{
+ let i = 0, n = name.length, c;
+ for( ; i < n; ++i ){
+ if(name.charCodeAt(i)<0x20){
+ err = "contains control character(s)";
+ break;
+ }
+ }
+ }
+ if(err){
+ F.error("Invalid name:",err);
+ }
+ return !err;
+ },
+
+ /**
+ If the given name is valid, a new page with that (trimmed) name
+ is added to the local stash.
+ */
+ addNewPage: function(name){
+ name = name.trim();
+ if(!this.validatePageName(name)) return false;
+ var wtype = 'normal';
+ if(0===name.indexOf('checkin/')) wtype = 'checkin';
+ else if(0===name.indexOf('branch/')) wtype = 'branch';
+ else if(0===name.indexOf('tag/')) wtype = 'tag';
+ /* ^^^ note that we're not validating that, e.g., checkin/XYZ
+ has a full artifact ID after "checkin/". */
+ const winfo = {
+ name: name, type: wtype, mimetype: 'text/x-fossil-wiki',
+ version: null, parent: null
+ };
+ $stash.updateWinfo(winfo, '');
+ this._rebuildList();
+ P.loadPage(winfo.name);
+ return true;
+ },
+
+ /**
+ Installs a wiki page selection list into the given parent DOM
+ element and loads the page list from the server.
+ */
+ init: function(parentElem){
+ const sel = D.select(), btn = D.addClass(D.button("Reload page list"), 'save');
+ this.e.select = sel;
+ D.addClass(parentElem, 'WikiList');
+ D.clearElement(parentElem);
+ D.append(
+ parentElem,
+ D.append(D.fieldset("Select a page to edit"),
+ sel)
+ );
+ D.attr(sel, 'size', 15);
+ D.option(D.disable(D.clearElement(sel)), "Loading...");
+
+ /** Set up filter checkboxes for the various types
+ of wiki pages... */
+ const fsFilter = D.fieldset("Wiki page types"),
+ fsFilterBody = D.div(),
+ filters = ['normal', 'branch', 'checkin', 'tag']
+ ;
+ D.append(fsFilter, fsFilterBody);
+ D.addClass(fsFilterBody, 'flex-container', 'flex-column', 'stretch');
+ const filterSelection = function(wtype, show){
+ sel.querySelectorAll('option[data-wtype='+wtype+']').forEach(function(opt){
+ if(show) opt.classList.remove('hidden');
+ else opt.classList.add('hidden');
+ });
+ };
+ const self = this;
+ filters.forEach(function(wtype){
+ const cbId = 'wtype-filter-'+wtype,
+ lbl = D.attr(D.append(D.label(),wtype),
+ 'for', cbId),
+ cb = D.attr(D.input('checkbox'), 'id', cbId),
+ span = D.append(D.span(), cb, lbl);
+ self.e.filterCheckboxes[wtype] = cb;
+ cb.checked = true;
+ filterSelection(wtype, cb.checked);
+ cb.addEventListener(
+ 'change',
+ function(ev){filterSelection(wtype, ev.target.checked)},
+ false
+ );
+ D.append(fsFilterBody, span);
+ });
+
+ /* A legend of the meanings of the symbols we use in
+ the OPTION elements to denote certain state. */
+ const fsLegend = D.fieldset("Edit status"),
+ fsLegendBody = D.div();
+ D.append(fsLegend, fsLegendBody);
+ D.addClass(fsLegendBody, 'flex-container', 'flex-column', 'stretch');
+ D.append(
+ fsLegendBody,
+ D.append(D.span(), P.config.editStateMarkers.isModified,
+ " = page has local edits"),
+ D.append(D.span(), P.config.editStateMarkers.isNew,
+ " = page is new/unsaved")
+ );
+
+ const fsNewPage = D.fieldset("Create new page"),
+ fsNewPageBody = D.div(),
+ newPageName = D.input('text'),
+ newPageBtn = D.button("Add page locally")
+ ;
+ D.append(parentElem, fsNewPage);
+ D.append(fsNewPage, fsNewPageBody);
+ D.addClass(fsNewPageBody, 'flex-container', 'flex-column', 'new-page');
+ D.append(
+ fsNewPageBody, newPageName, newPageBtn,
+ D.append(D.addClass(D.span(), 'mini-tip'),
+ "New pages exist only in this browser until they are saved.")
+ );
+ newPageBtn.addEventListener('click', function(){
+ if(self.addNewPage(newPageName.value)){
+ newPageName.value = '';
+ }
+ }, false);
+
+ D.append(
+ parentElem,
+ D.append(D.addClass(D.div(), 'fieldset-wrapper'),
+ fsFilter, fsNewPage, fsLegend)
+ );
+
+ D.append(parentElem, btn);
+ btn.addEventListener('click', ()=>this.loadList(), false);
+ this.loadList();
+ sel.addEventListener('change', (e)=>P.loadPage(e.target.value), false);
+ F.page.addEventListener('wiki-stash-updated', ()=>this.refreshStashMarks());
+ delete this.init;
+ }
+ };
+
+ /**
+ Keep track of how many in-flight AJAX requests there are so we
+ can disable input elements while any are pending. For
+ simplicity's sake we simply disable ALL OF IT while any AJAX is
+ pending, rather than disabling operation-specific UI elements,
+ which would be a huge maintenance hassle.
+
+ Noting, however, that this global on/off is not *quite*
+ pedantically correct. Pedantically speaking. If an element is
+ disabled before an XHR starts, this code "should" notice that and
+ not include it in the to-re-enable list. That would be annoying
+ to do, and becomes impossible to do properly once multiple XHRs
+ are in transit and an element is disabled seprately between two
+ of those in-transit requests (that would be an unlikely, but
+ possible, corner case).
+ */
+ const ajaxState = {
+ count: 0 /* in-flight F.fetch() requests */,
+ toDisable: undefined /* elements to disable during ajax activity */
+ };
+ F.fetch.beforesend = function f(){
+ if(!ajaxState.toDisable){
+ ajaxState.toDisable = document.querySelectorAll(
+ ['button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])'
+ ].join(',')
+ );
+ }
+ if(1===++ajaxState.count){
+ D.addClass(document.body, 'waiting');
+ D.disable(ajaxState.toDisable);
+ }
+ };
+ F.fetch.aftersend = function(){
+ if(0===--ajaxState.count){
+ D.removeClass(document.body, 'waiting');
+ D.enable(ajaxState.toDisable);
+ delete ajaxState.toDisable /* required to avoid enable/disable
+ race condition with the save button */;
+ }
+ };
+
+ F.onPageLoad(function() {
+ document.body.classList.add('wikiedit');
+ P.base = {tag: E('base'), wikiUrl: F.repoUrl('wiki')};
+ P.base.originalHref = P.base.tag.href;
+ P.e = { /* various DOM elements we work with... */
+ taEditor: E('#wikiedit-content-editor'),
+ btnReload: E("#wikiedit-tab-content button.wikiedit-content-reload"),
+ btnSave: E("button.wikiedit-save"),
+ selectMimetype: E('select[name=mimetype]'),
+ selectFontSizeWrap: E('#select-font-size'),
+// selectDiffWS: E('select[name=diff_ws]'),
+ cbAutoPreview: E('#cb-preview-autoupdate > input[type=checkbox]'),
+ previewTarget: E('#wikiedit-tab-preview-wrapper'),
+ diffTarget: E('#wikiedit-tab-diff-wrapper'),
+ attachmentWrapper: E("#wikiedit-attachments"),
+ tabContainer: E('#wikiedit-tabs'),
+ tabs:{
+ pageList: E('#wikiedit-tab-pages'),
+ content: E('#wikiedit-tab-content'),
+ preview: E('#wikiedit-tab-preview'),
+ diff: E('#wikiedit-tab-diff'),
+ misc: E('#wikiedit-tab-misc')
+ //commit: E('#wikiedit-tab-commit')
+ }
+ };
+ P.tabs = new fossil.TabManager(D.clearElement(P.e.tabContainer));
+ P.tabs.e.container.insertBefore(
+ /* Move the status bar between the tab buttons and
+ tab panels. Seems to be the best fit in terms of
+ functionality and visibility. */
+ E('#fossil-status-bar'), P.tabs.e.tabs
+ );
+ P.tabs.addEventListener(
+ /* Set up some before-switch-to tab event tasks... */
+ 'before-switch-to', function(ev){
+ const theTab = ev.detail, btnSlot = theTab.querySelector('.save-button-slot');
+ if(btnSlot){
+ /* Several places make sense for a save button, so we'll
+ move that button around to those tabs where it makes sense. */
+ btnSlot.parentNode.insertBefore( P.e.btnSave, btnSlot );
+ P.updateSaveButton();
+ }
+ if(theTab===P.e.tabs.preview){
+ P.baseHrefForWiki();
+ if(P.previewNeedsUpdate && P.e.cbAutoPreview.checked) P.preview();
+ }else if(theTab===P.e.tabs.diff){
+ /* Work around a weird bug where the page gets wider than
+ the window when the diff tab is NOT in view and the
+ current SBS diff widget is wider than the window. When
+ the diff IS in view then CSS overflow magically reduces
+ the page size again. Weird. Maybe FF-specific. Note that
+ this weirdness happens even though P.e.diffTarget's parent
+ is hidden (and therefore P.e.diffTarget is also hidden).
+ */
+ D.removeClass(P.e.diffTarget, 'hidden');
+ }else if(theTab===P.e.tabs.misc){
+ P.updateAttachmentView();
+ }
+ }
+ );
+ P.tabs.addEventListener(
+ /* Set up auto-refresh of the preview tab... */
+ 'before-switch-from', function(ev){
+ const theTab = ev.detail;
+ if(theTab===P.e.tabs.preview){
+ P.baseHrefRestore();
+ }else if(theTab===P.e.tabs.diff){
+ /* See notes in the before-switch-to handler. */
+ D.addClass(P.e.diffTarget, 'hidden');
+ }
+ }
+ );
+
+ F.connectPagePreviewers(
+ P.e.tabs.preview.querySelector(
+ '#btn-preview-refresh'
+ )
+ );
+
+ const diffButtons = E('#wikiedit-tab-diff-buttons');
+ diffButtons.querySelector('button.sbs').addEventListener(
+ "click",(e)=>P.diff(true), false
+ );
+ diffButtons.querySelector('button.unified').addEventListener(
+ "click",(e)=>P.diff(false), false
+ );
+ if(0) P.e.btnCommit.addEventListener(
+ "click",(e)=>P.commit(), false
+ );
+ F.confirmer(P.e.btnReload, {
+ confirmText: "Really reload, losing edits?",
+ onconfirm: function(e){
+ const w = P.winfo;
+ if(!w){
+ F.error("No page loaded.");
+ return;
+ }
+ if(!w.version/* new/unsaved page */
+ && w.type!=='sandbox'
+ && P.wikiContent()){
+ F.error("This new/unsaved page has content.",
+ "To really discard this page,",
+ "first clear its content",
+ "then use the Discard button.");
+ return;
+ }
+ P.unstashContent()
+ if(w.version || w.type==='sandbox'){
+ P.loadPage();
+ }else{
+ WikiList.removeEntry(w.name);
+ P.updatePageTitle();
+ delete P.winfo;
+ F.message("Discarded new page ["+w.name+"].");
+ }
+ },
+ ticks: 3
+ });
+ F.confirmer(P.e.btnSave, {
+ confirmText: "Really save changes?",
+ onconfirm: function(e){
+ const w = P.winfo;
+ if(!w){
+ F.error("No page loaded.");
+ return;
+ }
+ P.save();
+ },
+ ticks: 3
+ });
+
+ P.e.taEditor.addEventListener(
+ 'change', ()=>P.stashContentChange(), false
+ );
+
+ P.selectMimetype(false, true);
+ P.e.selectMimetype.addEventListener(
+ 'change',
+ function(e){
+ if(P.winfo){
+ P.winfo.mimetype = e.target.value;
+ P.stashContentChange(true);
+ }
+ },
+ false
+ );
+
+ const selectFontSize = E('select[name=editor_font_size]');
+ if(selectFontSize){
+ selectFontSize.addEventListener(
+ "change",function(e){
+ const ed = P.e.taEditor;
+ ed.className = ed.className.replace(
+ /\bfont-size-\d+/g, '' );
+ ed.classList.add('font-size-'+e.target.value);
+ }, false
+ );
+ selectFontSize.dispatchEvent(
+ // Force UI update
+ new Event('change',{target:selectFontSize})
+ );
+ }
+
+ P.addEventListener(
+ // Clear certain views when new content is loaded/set
+ 'wiki-content-replaced',
+ ()=>{
+ P.previewNeedsUpdate = true;
+ D.clearElement(P.e.diffTarget, P.e.previewTarget);
+ }
+ );
+ P.addEventListener(
+ // Clear certain views after a save
+ 'wiki-saved',
+ (e)=>{
+ D.clearElement(P.e.diffTarget, P.e.previewTarget);
+ // TODO: replace preview with new content
+ }
+ );
+ WikiList.init( P.e.tabs.pageList.firstElementChild );
+ P.addEventListener(
+ // Update various state on wiki page load
+ 'wiki-page-loaded',
+ function(ev){
+ delete P.winfo;
+ const winfo = ev.detail;
+ P.winfo = winfo;
+ P.previewNeedsUpdate = true;
+ P.e.selectMimetype.value = winfo.mimetype;
+ P.tabs.switchToTab(P.e.tabs.content);
+ P.wikiContent(winfo.content || '');
+ WikiList.e.select.value = winfo.name;
+ if(!winfo.version && winfo.type!=='sandbox'){
+ F.error('You are editing a new, unsaved page:',winfo.name);
+ }
+ P.updatePageTitle();
+ },
+ false
+ );
+ P.addEventListener('wiki-stash-updated', ()=>P.updateSaveButton())
+ .updatePageTitle().updateAttachmentView().updateSaveButton();
+ }/*F.onPageLoad()*/);
+
+ /**
+ Returns true if fossil.page.winfo is set, indicating that a page
+ has been loaded, else it reports an error and returns false.
+
+ If passed a truthy value any error message about not having
+ a wiki page loaded is suppressed.
+ */
+ const affirmPageLoaded = function(quiet){
+ if(!P.winfo && !quiet) F.error("No wiki page is loaded.");
+ return !!P.winfo;
+ };
+
+ /**
+ Update the page title and header based on the state of
+ this.winfo. A no-op if this.winfo is not set. Returns this.
+ */
+ P.updatePageTitle = function f(){
+ if(!f.titleElement){
+ f.titleElement = document.head.querySelector('title');
+ f.pageTitleHeader = document.querySelector('#wikiedit-page-name > span');
+ }
+ const title = ['Wiki Editor:'];
+ const wi = P.winfo;
+ if(wi){
+ if(!wi.version && 'sandbox'!==wi.type) title.push(P.config.editStateMarkers.isNew);
+ else if($stash.getWinfo(wi)) title.push(P.config.editStateMarkers.isModified)
+ title.push(wi.name);
+ }else{
+ title.push('(no page loaded)');
+ }
+ f.pageTitleHeader.innerText = title[1];
+ f.titleElement.innerText = title.join(' ');
+ return this;
+ };
+
+ /**
+ Change the save button depending on whether we have stuff to save
+ or not.
+ */
+ P.updateSaveButton = function(){
+ if(!this.winfo || !this.getStashedWinfo(this.winfo)){
+ D.disable(this.e.btnSave).innerText =
+ "There are no changes to save";
+ }else{
+ D.enable(this.e.btnSave).innerText = "Save changes";
+ }
+ return this;
+ };
+
+ /** Updates attachment-related links and returns this. */
+ P.updateAttachmentView = function(){
+ const wrapper = P.e.attachmentWrapper;
+ D.clearElement(wrapper);
+ const ul = D.ul();
+ D.append(wrapper, ul);
+ if(!P.winfo){
+ D.append(D.li(ul),
+ "Load a page to get access to its attachment-related pages.");
+ return this;
+ }else if(!P.winfo.version){
+ D.append(D.li(ul),
+ "A new/unsaved page cannot have attachments. Save it first.");
+ return this;
+ }
+ const wi = P.winfo;
+ D.append(
+ D.li(ul),
+ D.a(F.repoUrl('attachadd',{
+ page:wi.name,
+ from: F.repoUrl('wikiedit',{
+ name: wi.name
+ })
+ }), "Add attachments.")
+ );
+ D.append(
+ D.li(ul),
+ D.a(F.repoUrl('attachlist',{page:wi.name}),
+ "List attachments"),
+ " (if any)."
+ );
+ return this;
+ };
+
+ /**
+ Getter (if called with no args) or setter (if passed an arg) for
+ the current file content.
+
+ The setter form sets the content, dispatches a
+ 'wiki-content-replaced' event, and returns this object.
+ */
+ P.wikiContent = function f(){
+ if(0===arguments.length){
+ return f.get();
+ }else{
+ f.set(arguments[0] || '');
+ this.dispatchEvent('wiki-content-replaced', this);
+ return this;
+ }
+ };
+ /* Default get/set impls for file content */
+ P.wikiContent.get = function(){return P.e.taEditor.value};
+ P.wikiContent.set = function(content){P.e.taEditor.value = content};
+
+ /**
+ For use when installing a custom editor widget. Pass it the
+ getter and setter callbacks to fetch resp. set the content of the
+ custom widget. They will be triggered via
+ P.wikiContent(). Returns this object.
+ */
+ P.setContentMethods = function(getter, setter){
+ this.wikiContent.get = getter;
+ this.wikiContent.set = setter;
+ return this;
+ };
+
+ /**
+ Removes the default editor widget (and any dependent elements)
+ from the DOM, adds the given element in its place, removes this
+ method from this object, and returns this object.
+ */
+ P.replaceEditorElement = function(newEditor){
+ P.e.taEditor.parentNode.insertBefore(newEditor, P.e.taEditor);
+ P.e.taEditor.remove();
+ P.e.selectFontSizeWrap.remove();
+ delete this.replaceEditorElement;
+ return P;
+ };
+
+ /**
+ Sets the current page's base.href to {g.zTop}/wiki.
+ */
+ P.baseHrefForWiki = function f(){
+ this.base.tag.href = this.base.wikiUrl;
+ return this;
+ };
+
+ /**
+ Sets the document's base.href value to its page-load-time
+ setting.
+ */
+ P.baseHrefRestore = function(){
+ this.base.tag.href = this.base.originalHref;
+ };
+
+
+ /**
+ loadPage() loads the given wiki page and updates the relevant
+ UI elements to reflect the loaded state. If passed no arguments
+ then it re-uses the values from the currently-loaded page, reloading
+ it (emitting an error message if no file is loaded).
+
+ Returns this object, noting that the load is async. After loading
+ it triggers a 'wiki-page-loaded' event, passing it this.winfo.
+
+ If a locally-edited copy of the given file/rev is found, that
+ copy is used instead of one fetched from the server, but it is
+ still treated as a load event.
+
+ Alternate call forms:
+
+ - no arguments: re-loads from this.winfo.
+
+ - 1 non-string argument: assumed to be an winfo-style
+ object. Must have at least the {name} property, but need not have
+ other winfo state.
+ */
+ P.loadPage = function(name){
+ if(0===arguments.length){
+ /* Reload from this.winfo */
+ if(!affirmPageLoaded()) return this;
+ name = this.winfo.name;
+ }else if(1===arguments.length && 'string' !== typeof name){
+ /* Assume winfo-like object */
+ const arg = arguments[0];
+ name = arg.name;
+ }
+ const onload = (r)=>this.dispatchEvent('wiki-page-loaded', r);
+ const stashWinfo = this.getStashedWinfo({name: name});
+ if(stashWinfo){ // fake a response from the stash...
+ F.message("Fetched from the local-edit storage:",
+ stashWinfo.name);
+ onload({
+ name: stashWinfo.name,
+ mimetype: stashWinfo.mimetype,
+ type: stashWinfo.type,
+ version: stashWinfo.version,
+ parent: stashWinfo.parent,
+ content: $stash.stashedContent(stashWinfo)
+ });
+ return this;
+ }
+ F.message(
+ "Loading content..."
+ ).fetch('wikiajax/fetch',{
+ urlParams: {
+ page: name
+ },
+ responseType: 'json',
+ onload:(r)=>{
+ F.message('Loaded page ['+r.name+'].');
+ onload(r);
+ }
+ });
+ return this;
+ };
+
+ /**
+ Fetches the page preview based on the contents and settings of
+ this page's input fields, and updates the UI with with the
+ preview.
+
+ Returns this object, noting that the operation is async.
+ */
+ P.preview = function f(switchToTab){
+ if(!affirmPageLoaded()) return this;
+ const target = this.e.previewTarget,
+ self = this;
+ const updateView = function(c){
+ D.clearElement(target);
+ if('string'===typeof c) target.innerHTML = c;
+ if(switchToTab) self.tabs.switchToTab(self.e.tabs.preview);
+ };
+ return this._postPreview(this.wikiContent(), updateView);
+ };
+
+ /**
+ Callback for use with F.connectPagePreviewers()
+ */
+ P._postPreview = function(content,callback){
+ if(!affirmPageLoaded()) return this;
+ if(!content){
+ callback(content);
+ return this;
+ }
+ const fd = new FormData();
+ const mimetype = this.e.selectMimetype.value;
+ fd.append('page', this.winfo.name);
+ fd.append('mimetype',mimetype);
+ fd.append('content',content || '');
+ F.message(
+ "Fetching preview..."
+ ).fetch('wikiajax/preview',{
+ payload: fd,
+ onload: (r,header)=>{
+ callback(r);
+ F.message('Updated preview.');
+ P.previewNeedsUpdate = false;
+ P.dispatchEvent('wiki-preview-updated',{
+ mimetype: mimetype,
+ element: P.e.previewTarget
+ });
+ },
+ onerror: (e)=>{
+ fossil.fetch.onerror(e);
+ callback("Error fetching preview: "+e);
+ }
+ });
+ return this;
+ };
+
+ /**
+ Undo some of the SBS diff-rendering bits which hurt us more than
+ they help...
+ */
+ P.tweakSbsDiffs2 = function(){
+ if(1){
+ const dt = this.e.diffTarget;
+ dt.querySelectorAll('.sbsdiffcols .difftxtcol').forEach(
+ (dtc)=>{
+ const pre = dtc.querySelector('pre');
+ pre.style.width = 'initial';
+ //pre.removeAttribute('style');
+ //console.debug("pre width =",pre.style.width);
+ }
+ );
+ }
+ this.tweakSbsDiffs();
+ };
+
+ /**
+ Fetches the content diff based on the contents and settings of
+ this page's input fields, and updates the UI with the diff view.
+
+ Returns this object, noting that the operation is async.
+ */
+ P.diff = function f(sbs){
+ if(!affirmPageLoaded()) return this;
+ const content = this.wikiContent(),
+ self = this,
+ target = this.e.diffTarget;
+ const fd = new FormData();
+ fd.append('page',this.winfo.name);
+ fd.append('sbs', sbs ? 1 : 0);
+ fd.append('content',content);
+ if(this.e.selectDiffWS) fd.append('ws',this.e.selectDiffWS.value);
+ F.message(
+ "Fetching diff..."
+ ).fetch('wikiajax/diff',{
+ payload: fd,
+ onload: function(c){
+ target.innerHTML = [
+ "Diff [",
+ self.winfo.name,
+ "]
→ Local Edits
",
+ c||'No changes.'
+ ].join('');
+ if(sbs) P.tweakSbsDiffs2();
+ F.message('Updated diff.');
+ self.tabs.switchToTab(self.e.tabs.diff);
+ }
+ });
+ return this;
+ };
+
+ /**
+ Saves the current wiki page and re-populates the editor
+ with the saved state.
+ */
+ P.save = function callee(){
+ if(!affirmPageLoaded()) return this;
+ const self = this;
+ const content = this.wikiContent();
+ if(!callee.onload){
+ callee.onload = function(w){
+ const oldWinfo = self.winfo;
+ self.unstashContent(oldWinfo);
+ self.winfo = w;
+ self.updatePageTitle();
+ self.dispatchEvent('wiki-page-loaded', w);
+ F.message("Saved page: ["+w.name+"].");
+ }
+ }
+ const fd = new FormData(), w = P.winfo;
+ fd.append('page',w.name);
+ fd.append('mimetype', w.mimetype);
+ fd.append('isnew', w.version ? 0 : 1);
+ fd.append('content', P.wikiContent());
+ F.message(
+ "Saving page..."
+ ).fetch('wikiajax/save',{
+ payload: fd,
+ responseType: 'json',
+ onload: callee.onload
+ });
+ return this;
+ };
+
+ /**
+ Updates P.winfo for certain state and stashes P.winfo, with the
+ current content fetched via P.wikiContent().
+
+ If passed truthy AND the stash already has stashed content for
+ the current page, only the stashed winfo record is updated, else
+ both the winfo and content are updated.
+ */
+ P.stashContentChange = function(onlyWinfo){
+ if(affirmPageLoaded(true)){
+ const wi = this.winfo;
+ wi.mimetype = P.e.selectMimetype.value;
+ if(onlyWinfo && $stash.hasStashedContent(wi)){
+ $stash.updateWinfo(wi);
+ }else{
+ $stash.updateWinfo(wi, P.wikiContent());
+ }
+ F.message("Stashed change(s) to page ["+wi.name+"].");
+ P.updatePageTitle();
+ $stash.prune();
+ this.previewNeedsUpdate = true;
+ }
+ return this;
+ };
+
+ /**
+ Removes any stashed state for the current P.winfo (if set) from
+ F.storage. Returns this.
+ */
+ P.unstashContent = function(){
+ const winfo = arguments[0] || this.winfo;
+ if(winfo){
+ this.previewNeedsUpdate = true;
+ $stash.unstash(winfo);
+ //console.debug("Unstashed",winfo);
+ F.message("Unstashed page ["+winfo.name+"].");
+ }
+ return this;
+ };
+
+ /**
+ Clears all stashed file state from F.storage. Returns this.
+ */
+ P.clearStash = function(){
+ $stash.clear();
+ return this;
+ };
+
+ /**
+ If stashed content for P.winfo exists, it is returned, else
+ undefined is returned.
+ */
+ P.contentFromStash = function(){
+ return affirmPageLoaded(true) ? $stash.stashedContent(this.winfo) : undefined;
+ };
+
+ /**
+ If a stashed version of the given winfo object exists (same
+ filename/checkin values), return it, else return undefined.
+ */
+ P.getStashedWinfo = function(winfo){
+ return $stash.getWinfo(winfo);
+ };
+
+})(window.fossil);
Index: src/main.mk
==================================================================
--- src/main.mk
+++ src/main.mk
@@ -154,11 +154,10 @@
$(SRCDIR)/webmail.c \
$(SRCDIR)/wiki.c \
$(SRCDIR)/wikiformat.c \
$(SRCDIR)/winfile.c \
$(SRCDIR)/winhttp.c \
- $(SRCDIR)/wysiwyg.c \
$(SRCDIR)/xfer.c \
$(SRCDIR)/xfersetup.c \
$(SRCDIR)/zip.c
EXTRA_FILES = \
@@ -228,10 +227,11 @@
$(SRCDIR)/fossil.confirmer.js \
$(SRCDIR)/fossil.dom.js \
$(SRCDIR)/fossil.fetch.js \
$(SRCDIR)/fossil.page.fileedit.js \
$(SRCDIR)/fossil.page.forumpost.js \
+ $(SRCDIR)/fossil.page.wikiedit.js \
$(SRCDIR)/fossil.storage.js \
$(SRCDIR)/fossil.tabs.js \
$(SRCDIR)/graph.js \
$(SRCDIR)/href.js \
$(SRCDIR)/login.js \
@@ -257,10 +257,11 @@
$(SRCDIR)/sounds/d.wav \
$(SRCDIR)/sounds/e.wav \
$(SRCDIR)/sounds/f.wav \
$(SRCDIR)/style.admin_log.css \
$(SRCDIR)/style.fileedit.css \
+ $(SRCDIR)/style.wikiedit.css \
$(SRCDIR)/tree.js \
$(SRCDIR)/useredit.js \
$(SRCDIR)/wiki.wiki
TRANS_SRC = \
@@ -402,11 +403,10 @@
$(OBJDIR)/webmail_.c \
$(OBJDIR)/wiki_.c \
$(OBJDIR)/wikiformat_.c \
$(OBJDIR)/winfile_.c \
$(OBJDIR)/winhttp_.c \
- $(OBJDIR)/wysiwyg_.c \
$(OBJDIR)/xfer_.c \
$(OBJDIR)/xfersetup_.c \
$(OBJDIR)/zip_.c
OBJ = \
@@ -548,11 +548,10 @@
$(OBJDIR)/webmail.o \
$(OBJDIR)/wiki.o \
$(OBJDIR)/wikiformat.o \
$(OBJDIR)/winfile.o \
$(OBJDIR)/winhttp.o \
- $(OBJDIR)/wysiwyg.o \
$(OBJDIR)/xfer.o \
$(OBJDIR)/xfersetup.o \
$(OBJDIR)/zip.o
all: $(OBJDIR) $(APPNAME)
@@ -884,11 +883,10 @@
$(OBJDIR)/webmail_.c:$(OBJDIR)/webmail.h \
$(OBJDIR)/wiki_.c:$(OBJDIR)/wiki.h \
$(OBJDIR)/wikiformat_.c:$(OBJDIR)/wikiformat.h \
$(OBJDIR)/winfile_.c:$(OBJDIR)/winfile.h \
$(OBJDIR)/winhttp_.c:$(OBJDIR)/winhttp.h \
- $(OBJDIR)/wysiwyg_.c:$(OBJDIR)/wysiwyg.h \
$(OBJDIR)/xfer_.c:$(OBJDIR)/xfer.h \
$(OBJDIR)/xfersetup_.c:$(OBJDIR)/xfersetup.h \
$(OBJDIR)/zip_.c:$(OBJDIR)/zip.h \
$(SRCDIR)/sqlite3.h \
$(SRCDIR)/th.h \
@@ -2015,18 +2013,10 @@
$(OBJDIR)/winhttp.o: $(OBJDIR)/winhttp_.c $(OBJDIR)/winhttp.h $(SRCDIR)/config.h
$(XTCC) -o $(OBJDIR)/winhttp.o -c $(OBJDIR)/winhttp_.c
$(OBJDIR)/winhttp.h: $(OBJDIR)/headers
-$(OBJDIR)/wysiwyg_.c: $(SRCDIR)/wysiwyg.c $(OBJDIR)/translate
- $(OBJDIR)/translate $(SRCDIR)/wysiwyg.c >$@
-
-$(OBJDIR)/wysiwyg.o: $(OBJDIR)/wysiwyg_.c $(OBJDIR)/wysiwyg.h $(SRCDIR)/config.h
- $(XTCC) -o $(OBJDIR)/wysiwyg.o -c $(OBJDIR)/wysiwyg_.c
-
-$(OBJDIR)/wysiwyg.h: $(OBJDIR)/headers
-
$(OBJDIR)/xfer_.c: $(SRCDIR)/xfer.c $(OBJDIR)/translate
$(OBJDIR)/translate $(SRCDIR)/xfer.c >$@
$(OBJDIR)/xfer.o: $(OBJDIR)/xfer_.c $(OBJDIR)/xfer.h $(SRCDIR)/config.h
$(XTCC) -o $(OBJDIR)/xfer.o -c $(OBJDIR)/xfer_.c
Index: src/makemake.tcl
==================================================================
--- src/makemake.tcl
+++ src/makemake.tcl
@@ -164,11 +164,10 @@
webmail
wiki
wikiformat
winfile
winhttp
- wysiwyg
xfer
xfersetup
zip
http_ssl
}
Index: src/setup.c
==================================================================
--- src/setup.c
+++ src/setup.c
@@ -1077,24 +1077,14 @@
@ in forum posts, make this setting be "btw". The default is an
@ empty string which means that Fossil never allows Markdown documents
@ to generate unsafe HTML.
@ (Property: "safe-html")
@
- @
- onoff_attribute("Enable WYSIWYG Wiki Editing",
- "wysiwyg-wiki", "wysiwyg-wiki", 0, 0);
- @ Enable what-you-see-is-what-you-get (WYSIWYG) editing of wiki pages.
- @ The WYSIWYG editor generates HTML instead of markup, which makes
- @ subsequent manual editing more difficult.
- @ (Property: "wysiwyg-wiki")
- @
onoff_attribute("Use HTML as wiki markup language",
"wiki-use-html", "wiki-use-html", 0, 0);
@ Use HTML as the wiki markup language. Wiki links will still be parsed
- @ but all other wiki formatting will be ignored. This option is helpful
- @ if you have chosen to use a rich HTML editor for wiki markup such as
- @ TinyMCE.
+ @ but all other wiki formatting will be ignored.
@ CAUTION: when
@ enabling, all HTML tags and attributes are accepted in the wiki.
@ No sanitization is done. This means that it is very possible for malicious
@ users to inject dangerous HTML, CSS and JavaScript code into your wiki.
@ This should only be enabled when wiki editing is limited
Index: src/style.c
==================================================================
--- src/style.c
+++ src/style.c
@@ -1423,24 +1423,27 @@
** bootstrap the window.fossil object, using the built-in file
** fossil.bootstrap.js (not to be confused with bootstrap.js).
**
** Subsequent calls are no-ops.
**
-** If passed a true value, it emits the contents directly to the page
-** output, else it emits a script tag with a src=builtin/... to load
-** the script. It always outputs a small pre-bootstrap element in its
-** own script tag to initialize parts which need C-runtime-level
-** information, before loading the main fossil.bootstrap.js either
-** inline or via a \n");
}
}
+
+/*
+** Convenience wrapper which calls builtin_request_js() for a series
+** of builtin scripts named fossil.NAME.js. The first time it is
+** called, it also calls style_emit_script_fossil_bootstrap() to
+** initialize the window.fossil JS API. The first argument is a
+** no-meaning dummy required by the va_start() interface. All
+** subsequent arguments must be strings of the NAME part of
+** fossil.NAME.js, followed by a NULL argument to terminate the list.
+**
+** e.g. pass it (0, "fetch", "dom", "tabs", 0) to load those 3
+** APIs. Do not forget the trailing 0!
+*/
+void style_emit_fossil_js_apis( int dummy, ... ) {
+ static int once = 0;
+ const char *zArg;
+ char * zName;
+ va_list vargs;
+
+ if(0==once++){
+ style_emit_script_fossil_bootstrap(1);
+ }
+ va_start(vargs,dummy);
+ while( (zArg = va_arg (vargs, const char *))!=0 ){
+ zName = mprintf("fossil.%s.js", zArg);
+ builtin_request_js(zName);
+ fossil_free(zName);
+ }
+ va_end(vargs);
+}
Index: src/style.fileedit.css
==================================================================
--- src/style.fileedit.css
+++ src/style.fileedit.css
@@ -183,186 +183,5 @@
width: initial;
}
body.fileedit .sbsdiffcols div.difftxtcol pre {
max-width: 44em;
}
-
-/**
- Styles for fossil.tabs.js. As of this writing, currently
- only used by /fileedit, but it is anticipated that these
- will eventually need to migrate to default_css.txt for use
- in the wiki and/or forum pages when implementing tabbed
- ajax-based previews.
-*/
-.tab-container {
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: stretch;
-}
-.tab-container > #fossil-status-bar {
- margin-top: 0;
-}
-.tab-container > .tabs {
- padding: 0.25em;
- margin: 0;
- display: flex;
- flex-direction: column;
- border-width: 1px;
- border-style: outset;
- border-color: inherit;
-}
-.tab-container > .tabs > .tab-panel {
- align-self: stretch;
- flex: 10 1 auto;
- display: block;
-}
-.tab-container > .tab-bar {
- display: flex;
- flex-direction: row;
- flex: 1 10 auto;
- align-self: stretch;
- flex-wrap: wrap;
-}
-.tab-container > .tab-bar > .tab-button {
- display: inline-block;
- border-radius: 0.5em 0.5em 0 0;
- margin: 0 0.1em;
- padding: 0.25em 0.75em;
- align-self: baseline;
- border-color: inherit;
- border-width: 1px;
- border-bottom: none;
- border-top-style: inset;
- border-left-style: inset;
- border-right-style: inset;
- cursor: pointer;
- opacity: 0.6;
-}
-.tab-container > .tab-bar > .tab-button.selected {
- text-decoration: underline;
- opacity: 1.0;
- border-top-style: outset;
- border-left-style: outset;
- border-right-style: outset;
-}
-
-/**
- Styles developed for /fileedit but which have wider
- applicability...
-
- As of this writing, these are only used by /fileedit, but it is
- anticipated that they will eventually need to be migrated over to
- default_css.txt for use in other pages (specifically wiki and forum
- page/post editors).
-*/
-.flex-container {
- display: flex;
-}
-.flex-container.flex-row {
- flex-direction: row;
- flex-wrap: wrap;
- justify-content: center;
- align-items: center;
-}
-.flex-container .flex-grow {
- flex-grow: 10;
- flex-shrink: 0;
-}
-.flex-container .flex-shrink {
- flex-grow: 0;
- flex-shrink: 10;
-}
-.flex-container.flex-row.stretch {
- flex-wrap: wrap;
- align-items: baseline;
- justify-content: stretch;
- margin: 0;
-}
-.flex-container.flex-column {
- flex-direction: column;
- flex-wrap: wrap;
- justify-content: center;
- align-items: center;
-}
-.flex-container.flex-column.stretch {
- align-items: stretch;
- margin: 0;
-}
-.flex-container.child-gap-small > * {
- margin: 0.25em;
-}
-#fossil-status-bar {
- display: block;
- font-family: monospace;
- border-width: 1px;
- border-style: inset;
- border-color: inherit;
- min-height: 1.5em;
- font-size: 1.2em;
- padding: 0.2em;
- margin: 0.25em 0;
- flex: 0 0 auto;
-}
-.font-size-100 {
- font-size: 100%;
-}
-.font-size-125 {
- font-size: 125%;
-}
-.font-size-150 {
- font-size: 150%;
-}
-.font-size-175 {
- font-size: 175%;
-}
-.font-size-200 {
- font-size: 200%;
-}
-
-/**
- .input-with-label is intended to be a wrapper element which
- contain both a LABEL tag and an INPUT or SELECT control.
- The wrapper is "necessary", as opposed to placing the INPUT
- in the LABEL, so that we can include multiple INPUT
- elements (e.g. a set of radio buttons).
-*/
-.input-with-label {
- border: 1px inset #808080;
- border-radius: 0.5em;
- padding: 0.25em 0.4em;
- margin: 0 0.5em;
- display: inline-block;
- cursor: default;
-}
-.input-with-label > * {
- vertical-align: middle;
-}
-.input-with-label > label {
- display: inline; /* some skins set label display to block! */
-}
-.input-with-label > input {
- margin: 0;
-}
-.input-with-label > button {
- margin: 0;
-}
-.input-with-label > select {
- margin: 0;
-}
-.input-with-label > input[type=text] {
- margin: 0;
-}
-.input-with-label > textarea {
- margin: 0;
-}
-.input-with-label > input[type=checkbox] {
- vertical-align: sub;
-}
-.input-with-label > input[type=radio] {
- vertical-align: sub;
-}
-.input-with-label > label {
- font-weight: initial;
- margin: 0 0.25em 0 0.25em;
- vertical-align: middle;
-}
ADDED src/style.wikiedit.css
Index: src/style.wikiedit.css
==================================================================
--- /dev/null
+++ src/style.wikiedit.css
@@ -0,0 +1,124 @@
+body.wikieedit.waiting * {
+ /* Triggered during AJAX requests. */
+ cursor: wait;
+}
+body.wikiedit textarea,
+body.wikiedit textarea:focus,
+body.wikiedit input,
+body.wikiedit input:focus,
+body.wikiedit select,
+body.wikiedit select:focus{
+ /* The sudden appearance of a border (as in the Ardoise skin)
+ shifts the layout in unsightly ways */
+ border: initial;
+}
+body.wikiedit div.wikiedit-preview {
+ margin: 0;
+ padding: 0;
+}
+body.wikiedit #wikiedit-tabs {
+ margin: 1em 0 0 0;
+}
+body.wikiedit #wikiedit-tab-preview-wrapper {
+ overflow: auto;
+}
+body.wikiedit .tab-container > .tabs > .tab-panel > .wikiedit-options {
+ margin-top: 0;
+ border: none;
+ border-radius: 0;
+ border-bottom-width: 1px;
+ border-bottom-style: dotted;
+}
+body.wikiedit .tab-container > .tabs > .tab-panel > .wikiedit-options > button {
+ vertical-align: middle;
+ margin: 0.5em;
+}
+body.wikiedit .tab-container > .tabs > .tab-panel > .wikiedit-options > input {
+ vertical-align: middle;
+ margin: 0.5em;
+}
+body.wikiedit .tab-container > .tabs > .tab-panel > .wikiedit-options > .input-with-label {
+ vertical-align: middle;
+ margin: 0.5em;
+}
+body.wikiedit .wikiedit-options > div > * {
+ margin: 0.25em;
+}
+body.wikiedit .wikiedit-options.flex-container.flex-row {
+ align-items: first baseline;
+}
+body.wikiedit .WikiList {
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+}
+body.wikiedit .WikiList select {
+ font-size: 110%;
+ margin: initial;
+ height: initial /* some skins set these to a fix height */;
+}
+body.wikiedit .WikiList select option {
+ margin: 0.5em 0;
+}
+body.wikiedit .WikiList select option.stashed::before {
+/* Maintenance reminder: the option.stashed/stashed-new "content" values
+ are duplicated in fossil.page.wikiedit.js and need to be changed there
+ if they are changed here: see fossil.page.config.editStateMarkers */
+ content: "[*] ";
+}
+body.wikiedit .WikiList select option.stashed-new::before {
+ content: "[+] ";
+}
+body.wikiedit textarea {
+ max-width: initial;
+}
+body.wikiedit .tabs .tab-panel {
+ /* Needed for wide diffs */
+ overflow: auto;
+}
+body.wikiedit .WikiList fieldset {
+ padding: 0.25em;
+ border-width: 1px /* Ardoise skin sets this to 0 */;
+}
+body.wikiedit .WikiList legend {
+ font-size: 90%;
+}
+body.wikiedit .WikiList fieldset > :not(legend) {
+ /* Stretch page selection list when it's empty or only has short page names */
+ width: 100%;
+}
+body.wikiedit .WikiList .fieldset-wrapper {
+ /* Container for the filter and edit status fieldsets */
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: stretch;
+ justify-content: stretch;
+ margin: 0;
+}
+body.wikiedit .WikiList button.save {
+ margin: 1em 0 0 0;
+}
+body.wikiedit .WikiList .new-page {
+ align-items: flex-start;
+ max-width: 15em;
+}
+body.wikiedit .WikiList .new-page input {
+}
+body.wikiedit #wikiedit-tab-misc h3 {
+ margin: 0;
+}
+body.wikiedit span.mini-tip {
+ font-size: 80%;
+}
+
+body.wikiedit span.save-button-slot {
+ /* These invisible placeholders mark spots in the UI
+ (max. 1 per tab) to where the save button gets
+ relocated as we switch between tabs. */
+ display: none;
+}
+
+body.wikiedit #wikiedit-page-name > span {
+ font-family: monospace;
+}
Index: src/wiki.c
==================================================================
--- src/wiki.c
+++ src/wiki.c
@@ -102,11 +102,10 @@
" WHERE tagid=%d AND mtime<%.16g"
" ORDER BY mtime DESC LIMIT 1",
tagid, mtime);
}
-
/*
** WEBPAGE: home
** WEBPAGE: index
** WEBPAGE: not_found
**
@@ -398,10 +397,24 @@
if( sqlite3_strglob("tag/*", zPageName)==0 ){
return WIKITYPE_TAG;
}
return WIKITYPE_NORMAL;
}
+
+/*
+** Returns a JSON-friendly string form of the integer value returned
+** by wiki_page_type(zPageName).
+*/
+const char * wiki_page_type_name(const char *zPageName){
+ switch(wiki_page_type(zPageName)){
+ case WIKITYPE_CHECKIN: return "checkin";
+ case WIKITYPE_BRANCH: return "branch";
+ case WIKITYPE_TAG: return "tag";
+ case WIKITYPE_NORMAL:
+ default: return "normal";
+ }
+}
/*
** Add an appropriate style_header() for either the /wiki or /wikiedit page
** for zPageName. zExtra is an empty string for /wiki but has the text
** "Edit: " for /wikiedit.
@@ -541,16 +554,11 @@
zMimetype = wiki_filter_mimetypes(zMimetype);
if( !g.isHome && !noSubmenu ){
if( ((rid && g.perm.WrWiki) || (!rid && g.perm.NewWiki))
&& wiki_special_permission(zPageName)
){
- if( db_get_boolean("wysiwyg-wiki", 0) ){
- style_submenu_element("Edit", "%R/wikiedit?name=%T&wysiwyg=1",
- zPageName);
- }else{
- style_submenu_element("Edit", "%R/wikiedit?name=%T", zPageName);
- }
+ style_submenu_element("Edit", "%R/wikiedit?name=%T", zPageName);
}else if( rid && g.perm.ApndWiki ){
style_submenu_element("Edit", "%R/wikiappend?name=%T", zPageName);
}
if( g.perm.Hyperlink ){
style_submenu_element("History", "%R/whistory?name=%T", zPageName);
@@ -620,220 +628,656 @@
return azStyles[i+1];
}
}
return azStyles[1];
}
+
+/*
+ ** Tries to fetch a wiki page for the given name. If found, it
+ ** returns true, else false.
+ **
+ ** versionsBack specifies how many versions back in the history to
+ ** fetch. Use 0 for the latest version, 1 for its parent, etc.
+ **
+ ** If pRid is not NULL then if a result is found *pRid is set to its
+ ** RID. If ppWiki is not NULL then if found *ppWiki is set to the
+ ** loaded wiki object, which the caller is responsible for passing to
+ ** manifest_destroy().
+ */
+static int wiki_fetch_by_name( const char *zPageName,
+ unsigned int versionsBack,
+ int * pRid, Manifest **ppWiki ){
+ Manifest *pWiki = 0;
+ char *zTag = mprintf("wiki-%s", zPageName);
+ Stmt q = empty_Stmt;
+ int rid = 0;
+
+ db_prepare(&q, "SELECT rid FROM tagxref"
+ " WHERE tagid=(SELECT tagid FROM tag WHERE"
+ " tagname=%Q) "
+ " ORDER BY mtime DESC LIMIT -1 OFFSET %u", zTag,
+ versionsBack);
+ fossil_free(zTag);
+ if(SQLITE_ROW == db_step(&q)){
+ rid = db_column_int(&q, 0);
+ }
+ db_finalize(&q);
+ if( rid == 0 ){
+ return 0;
+ }
+ else if(pRid){
+ *pRid = rid;
+ }
+ if(ppWiki){
+ pWiki = manifest_get(rid, CFTYPE_WIKI, 0);
+ if( pWiki==0 ){
+ /* "Cannot happen." */
+ return 0;
+ }
+ *ppWiki = pWiki;
+ }
+ return 1;
+}
+
+/*
+** Determines whether the wiki page with the given name can be edited
+** or created by the current user. If not, an AJAX error is queued and
+** false is returned, else true is returned. A NULL, empty, or
+** malformed name is considered non-writable, regardless of the user.
+**
+** If pRid is not NULL then this function writes the page's rid to
+** *pRid (whether or not access is granted). On error or if the page
+** does not yet exist, *pRid will be set to 0.
+**
+** Note that the sandbox is a special case: it is a pseudo-page with
+** no rid and the /wikiajax API does not allow anyone to actually save
+** a sandbox page, but it is reported as writable here (with rid 0).
+*/
+static int wiki_ajax_can_write(const char *zPageName, int * pRid){
+ int rid = 0;
+ const char * zErr = 0;
+
+ if(pRid) *pRid = 0;
+ if(!zPageName || !*zPageName
+ || !wiki_name_is_wellformed((unsigned const char *)zPageName)){
+ zErr = "Invalid page name.";
+ }else if(is_sandbox(zPageName)){
+ return 1;
+ }else{
+ wiki_fetch_by_name(zPageName, 0, &rid, 0);
+ if(pRid) *pRid = rid;
+ if(!wiki_special_permission(zPageName)){
+ zErr = "Editing this page requires non-wiki write permissions.";
+ }else if( (rid && g.perm.WrWiki) || (!rid && g.perm.NewWiki) ){
+ return 3;
+ }else if(rid && !g.perm.WrWiki){
+ zErr = "Requires wiki-write permissions.";
+ }else if(!rid && !g.perm.NewWiki){
+ zErr = "Requires new-wiki permissions.";
+ }else{
+ zErr = "Cannot happen! Please report this as a bug.";
+ }
+ }
+ ajax_route_error(403, "%s", zErr);
+ return 0;
+}
+
+/*
+** Loads the given wiki page, sets the response type to
+** application/json, and emits it as a JSON object. If zPageName is a
+** sandbox page then a "fake" object is emitted, as the wikiajax API
+** does not permit saving the sandbox.
+**
+** Returns true on success, false on error, and on error it
+** queues up a JSON-format error response.
+**
+** Output JSON format:
+**
+** { name: "page name",
+** type: "normal" | "tag" | "checkin" | "branch" | "sandbox",
+** mimetype: "mime type",
+** version: UUID string or null for a sandbox page,
+** parent: "parent uuid" or null if no parent,
+** content: "page content"
+** }
+**
+** If includeContent is false then the content member is elided.
+*/
+static int wiki_ajax_emit_page_object(const char *zPageName,
+ int includeContent){
+ Manifest * pWiki = 0;
+ char * zUuid;
+
+ cgi_set_content_type("application/json");
+ if( is_sandbox(zPageName) ){
+ char * zMimetype =
+ db_get("sandbox-mimetype","text/x-fossil-wiki");
+ char * zBody = db_get("sandbox","");
+ CX("{\"name\": %!j, \"type\": \"sandbox\", "
+ "\"mimetype\": %!j, \"version\": null, \"parent\": null",
+ zPageName, zMimetype);
+ if(includeContent){
+ CX(", \"content\": %!j",
+ zBody);
+ }
+ CX("}");
+ fossil_free(zMimetype);
+ fossil_free(zBody);
+ return 1;
+ }else if( !wiki_fetch_by_name(zPageName, 0, 0, &pWiki) ){
+ ajax_route_error(404, "Wiki page could not be loaded: %s",
+ zPageName);
+ return 0;
+ }else{
+ zUuid = rid_to_uuid(pWiki->rid);
+ CX("{\"name\": %!j, \"type\": %!j, "
+ "\"version\": %!j, "
+ "\"mimetype\": %!j, ",
+ pWiki->zWikiTitle,
+ wiki_page_type_name(pWiki->zWikiTitle),
+ zUuid,
+ pWiki->zMimetype ? pWiki->zMimetype : "text/x-fossil-wiki");
+ CX("\"parent\": ");
+ if(pWiki->nParent){
+ CX("%!j", pWiki->azParent[0]);
+ }else{
+ CX("null");
+ }
+ if(includeContent){
+ CX(", \"content\": %!j", pWiki->zWiki);
+ }
+ CX("}");
+ fossil_free(zUuid);
+ manifest_destroy(pWiki);
+ return 2;
+ }
+}
+
+/*
+** Ajax route handler for /wikiajax/save.
+**
+** URL params:
+**
+** page = the wiki page name.
+** mimetype = content mime type.
+** content = page content. Fossil considers an empty page to
+** be "deleted".
+** isnew = 1 if the page is to be newly-created, else 0 or
+** not send.
+**
+** Responds with JSON. On error, an object in the form documented by
+** ajax_route_error(). On success, an object in the form documented
+** for wiki_ajax_emit_page_object().
+**
+** The wikiajax API disallows saving of a sandbox pseudo-page, and
+** will respond with an error if asked to save one.
+**
+** Reminder: the original implementation implements sandbox-page
+** saving using:
+**
+** db_set("sandbox",zBody,0);
+** db_set("sandbox-mimetype",zMimetype,0);
+**
+*/
+static void wiki_ajax_route_save(void){
+ const char *zPageName = P("page");
+ const char *zMimetype = P("mimetype");
+ const char *zContent = P("content");
+ const int isNew = atoi(PD("isnew","0"))==1;
+ Blob content = empty_blob;
+ int parentRid = 0;
+ int rollback = 0;
+
+ if(!wiki_ajax_can_write(zPageName, &parentRid)){
+ return;
+ }else if(is_sandbox(zPageName)){
+ ajax_route_error(403,"Saving a sandbox page is prohibited.");
+ return;
+ }
+
+ /* These isNew checks are just me being pedantic. The hope is
+ to avoid accidental addition of new pages which differ only
+ by the case of their name. We could just as easily derive
+ isNew based on whether or not the page already exists. */
+ if(isNew){
+ if(parentRid>0){
+ ajax_route_error(403,"Requested a new page, "
+ "but it already exists with RID %d: %s",
+ parentRid, zPageName);
+ return;
+ }
+ }else if(parentRid==0){
+ ajax_route_error(403,"Creating new page [%s] requires passing "
+ "isnew=1.", zPageName);
+ return;
+ }
+
+ blob_init(&content, zContent ? zContent : "", -1);
+ db_begin_transaction();
+ wiki_cmd_commit(zPageName, parentRid, &content, zMimetype, 0);
+ rollback = wiki_ajax_emit_page_object(zPageName, 1) ? 0 : 1;
+ db_end_transaction(rollback);
+}
+
+/*
+** Ajax route handler for /wikiajax/fetch.
+**
+** URL params:
+**
+** page = the wiki page name
+**
+** Responds with JSON. On error, an object in the form documented by
+** ajax_route_error(). On success, an object in the form documented
+** for wiki_ajax_emit_page_object().
+*/
+static void wiki_ajax_route_fetch(void){
+ const char * zPageName = P("page");
+
+ if( zPageName==0 || zPageName[0]==0 ){
+ ajax_route_error(400,"Missing page name.");
+ return;
+ }
+ wiki_ajax_emit_page_object(zPageName, 1);
+}
+
+/*
+** Ajax route handler for /wikiajax/diff.
+**
+** URL params:
+**
+** page = the wiki page name
+** content = the new/edited wiki page content
+**
+** Requires that the user have write access solely to avoid some
+** potential abuse cases. It does not actually write anything.
+*/
+static void wiki_ajax_route_diff(void){
+ const char * zPageName = P("page");
+ Blob contentNew = empty_blob, contentOrig = empty_blob;
+ Manifest * pParent = 0;
+ const char * zContent = P("content");
+ u64 diffFlags = DIFF_HTML | DIFF_NOTTOOBIG | DIFF_STRIP_EOLCR;
+
+ if( zPageName==0 || zPageName[0]==0 ){
+ ajax_route_error(400,"Missing page name.");
+ return;
+ }else if(!wiki_ajax_can_write(zPageName, 0)){
+ return;
+ }
+ switch(atoi(PD("sbs","0"))){
+ case 0: diffFlags |= DIFF_LINENO; break;
+ default: diffFlags |= DIFF_SIDEBYSIDE;
+ }
+ switch(atoi(PD("ws","2"))){
+ case 1: diffFlags |= DIFF_IGNORE_EOLWS; break;
+ case 2: diffFlags |= DIFF_IGNORE_ALLWS; break;
+ default: break;
+ }
+ wiki_fetch_by_name( zPageName, 0, 0, &pParent );
+ if( pParent && pParent->zWiki && *pParent->zWiki ){
+ blob_init(&contentOrig, pParent->zWiki, -1);
+ }else{
+ blob_init(&contentOrig, "", 0);
+ }
+ blob_init(&contentNew, zContent ? zContent : "", -1);
+ cgi_set_content_type("text/html");
+ ajax_render_diff(&contentOrig, &contentNew, diffFlags);
+ blob_reset(&contentNew);
+ blob_reset(&contentOrig);
+ manifest_destroy(pParent);
+}
+
+/*
+** Ajax route handler for /wikiajax/preview.
+**
+** URL params:
+**
+** page = wiki page name. This is only needed for authorization
+** checking.
+** mimetype = the wiki page mimetype (determines rendering style)
+** content = the wiki page content
+*/
+static void wiki_ajax_route_preview(void){
+ const char * zPageName = P("page");
+ const char * zContent = P("content");
+
+ if(!wiki_ajax_can_write(zPageName, 0)){
+ return;
+ }else if( zContent==0 ){
+ ajax_route_error(400,"Missing content to preview.");
+ return;
+ }else{
+ Blob content = empty_blob;
+ const char * zMimetype = PD("mimetype","text/x-fossil-wiki");
+
+ blob_init(&content, zContent, -1);
+ cgi_set_content_type("text/html");
+ wiki_render_by_mimetype(&content, zMimetype);
+ blob_reset(&content);
+ }
+}
+
+/*
+** Ajax route handler for /wikiajax/list.
+**
+** Optional parameters: verbose, includeContent (see below).
+**
+** Responds with JSON. On error, an object in the form documented by
+** ajax_route_error().
+**
+** On success, it emits an array of strings (page names) sorted
+** case-insensitively. If the "verbose" parameter is passed in then
+** the result list contains objects in the format documented for
+** wiki_ajax_emit_page_object(). The content of each object is elided
+** unless the "includeContent" parameter is passed on.
+**
+** The result list always contains an entry
+** named "sandbox" which represents the sandbox pseudo-page.
+*/
+static void wiki_ajax_route_list(void){
+ Stmt q = empty_Stmt;
+ int n = 0;
+ const int verbose = ajax_p_bool("verbose");
+ const int includeContent = ajax_p_bool("includeContent");
+
+ cgi_set_content_type("application/json");
+ db_begin_transaction();
+ db_prepare(&q, "SELECT"
+ " substr(tagname,6) AS name"
+ " FROM tag WHERE tagname GLOB 'wiki-*'"
+ " UNION SELECT 'Sandbox' AS name"
+ " ORDER BY name COLLATE NOCASE");
+ CX("[");
+ while( SQLITE_ROW==db_step(&q) ){
+ char const * zName = db_column_text(&q,0);
+ if(n++){
+ CX(",");
+ }
+ if(verbose==0){
+ CX("%!j", zName);
+ }else{
+ wiki_ajax_emit_page_object(zName, includeContent);
+ }
+ }
+ db_finalize(&q);
+ db_end_transaction(0);
+ CX("]");
+}
+
+
+/*
+** WEBPAGE: wikiajax
+**
+** An internal dispatcher for wiki AJAX operations. Not for direct
+** client use. All routes defined by this interface are app-internal,
+** subject to change
+*/
+void wiki_ajax_page(void){
+ const char * zName = P("name");
+ AjaxRoute routeName = {0,0,0,0};
+ const AjaxRoute * pRoute = 0;
+ const AjaxRoute routes[] = {
+ /* Keep these sorted by zName (for bsearch()) */
+ {"diff", wiki_ajax_route_diff, 1, 1},
+ {"fetch", wiki_ajax_route_fetch, 0, 0},
+ {"list", wiki_ajax_route_list, 0, 0},
+ {"preview", wiki_ajax_route_preview, 0, 1}
+ /* preview access mode: whether or not wiki-write mode is needed
+ really depends on multiple factors. e.g. the sandbox page does
+ not normally require more than anonymous access. We set its
+ write-mode to false and do those checks manually in that route's
+ handler.
+ */,
+ {"save", wiki_ajax_route_save, 1, 1}
+ };
+
+ if(zName==0 || zName[0]==0){
+ ajax_route_error(400,"Missing required [route] 'name' parameter.");
+ return;
+ }
+ routeName.zName = zName;
+ pRoute = (const AjaxRoute *)bsearch(&routeName, routes,
+ count(routes), sizeof routes[0],
+ cmp_ajax_route_name);
+ if(pRoute==0){
+ ajax_route_error(404,"Ajax route not found.");
+ return;
+ }
+ login_check_credentials();
+ if( pRoute->bWriteMode!=0 && g.perm.WrWiki==0 ){
+ ajax_route_error(403,"Write permissions required.");
+ return;
+ }else if(0==cgi_csrf_safe(pRoute->bPost)){
+ ajax_route_error(403,
+ "CSRF violation (make sure sending of HTTP "
+ "Referer headers is enabled for XHR "
+ "connections).");
+ return;
+ }
+ pRoute->xCallback();
+}
/*
** WEBPAGE: wikiedit
-** URL: /wikiedit?name=PAGENAME
+** URL: /wikedit?name=PAGENAME
+**
+** The main front-end for the Ajax-based wiki editor app. Passing
+** in the name of an unknown page will trigger the creation
+** of a new page (which is not actually created in the database
+** until the user explicitly saves it). If passed no page name,
+** the user may select a page from the list on the first UI tab.
**
-** Edit a wiki page.
+** When creating a new page, the mimetype URL parameter may optionally
+** be used to set its mimetype to one of text/x-fossil-wiki,
+** text/x-markdown, or text/plain, defauling to the former.
*/
void wikiedit_page(void){
- char *zTag;
- int rid = 0;
+ const char *zPageName;
+ const char * zMimetype = P("mimetype");
int isSandbox;
- Blob wiki;
- Manifest *pWiki = 0;
- const char *zPageName;
- int n;
- const char *z;
- char *zBody = (char*)P("w");
- const char *zMimetype = wiki_filter_mimetypes(P("mimetype"));
- int isWysiwyg = P("wysiwyg")!=0;
- int goodCaptcha = 1;
- int eType = WIKITYPE_UNKNOWN;
- int havePreview = 0;
-
- if( P("edit-wysiwyg")!=0 ){ isWysiwyg = 1; zBody = 0; }
- if( P("edit-markup")!=0 ){ isWysiwyg = 0; zBody = 0; }
- if( zBody ){
- if( isWysiwyg ){
- Blob body;
- blob_zero(&body);
- htmlTidy(zBody, &body);
- zBody = blob_str(&body);
- }else{
- zBody = mprintf("%s", zBody);
- }
- }
+ int found = 0;
+
login_check_credentials();
zPageName = PD("name","");
- if( check_name(zPageName) ) return;
+ if(zPageName && *zPageName){
+ if( check_name(zPageName) ) return;
+ }
isSandbox = is_sandbox(zPageName);
if( isSandbox ){
if( !g.perm.WrWiki ){
login_needed(g.anon.WrWiki);
return;
}
- if( zBody==0 ){
- zBody = db_get("sandbox","");
- zMimetype = db_get("sandbox-mimetype","text/x-fossil-wiki");
- }
- }else{
- zTag = mprintf("wiki-%s", zPageName);
- rid = db_int(0,
- "SELECT rid FROM tagxref"
- " WHERE tagid=(SELECT tagid FROM tag WHERE tagname=%Q)"
- " ORDER BY mtime DESC", zTag
- );
- free(zTag);
+ found = 1;
+ }else if( zPageName!=0 ){
+ int rid = 0;
if( !wiki_special_permission(zPageName) ){
login_needed(0);
return;
}
+ found = wiki_fetch_by_name(zPageName, 0, &rid, 0);
if( (rid && !g.perm.WrWiki) || (!rid && !g.perm.NewWiki) ){
login_needed(rid ? g.anon.WrWiki : g.anon.NewWiki);
return;
}
- if( zBody==0 && (pWiki = manifest_get(rid, CFTYPE_WIKI, 0))!=0 ){
- zBody = pWiki->zWiki;
- zMimetype = pWiki->zMimetype;
- }
- }
- if( P("submit")!=0 && zBody!=0
- && (goodCaptcha = captcha_is_correct(0))
- ){
- char *zDate;
- Blob cksum;
- blob_zero(&wiki);
- db_begin_transaction();
- if( isSandbox ){
- db_set("sandbox",zBody,0);
- db_set("sandbox-mimetype",zMimetype,0);
- }else{
- login_verify_csrf_secret();
- zDate = date_in_standard_format("now");
- blob_appendf(&wiki, "D %s\n", zDate);
- free(zDate);
- blob_appendf(&wiki, "L %F\n", zPageName);
- if( fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
- blob_appendf(&wiki, "N %s\n", zMimetype);
- }
- if( rid ){
- char *zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
- blob_appendf(&wiki, "P %s\n", zUuid);
- free(zUuid);
- }
- if( !login_is_nobody() ){
- blob_appendf(&wiki, "U %F\n", login_name());
- }
- blob_appendf(&wiki, "W %d\n%s\n", strlen(zBody), zBody);
- md5sum_blob(&wiki, &cksum);
- blob_appendf(&wiki, "Z %b\n", &cksum);
- blob_reset(&cksum);
- wiki_put(&wiki, 0, wiki_need_moderation(0));
- }
- db_end_transaction(0);
- cgi_redirectf("wiki?name=%T", zPageName);
- }
- if( P("cancel")!=0 ){
- cgi_redirectf("wiki?name=%T", zPageName);
- return;
- }
- if( zBody==0 ){
- zBody = mprintf("");
- }
- style_set_current_page("%T?name=%T", g.zPath, zPageName);
- eType = wiki_page_header(WIKITYPE_UNKNOWN, zPageName, "Edit: ");
- if( rid && !isSandbox && g.perm.ApndWiki ){
- if( g.perm.Attach ){
- style_submenu_element("Attach",
- "%s/attachadd?page=%T&from=%s/wiki%%3fname=%T",
- g.zTop, zPageName, g.zTop, zPageName);
- }
- }
- if( !goodCaptcha ){
- @
Error: Incorrect security code.
- }
- blob_zero(&wiki);
- while( fossil_isspace(zBody[0]) ) zBody++;
- blob_append(&wiki, zBody, -1);
- if( P("preview")!=0 ){
- havePreview = 1;
- if( zBody[0] ){
- @ Preview:
- safe_html_context(DOCSRC_WIKI);
- wiki_render_by_mimetype(&wiki, zMimetype);
- @
- blob_reset(&wiki);
- }
- }
- for(n=2, z=zBody; z[0]; z++){
- if( z[0]=='\n' ) n++;
- }
- if( n<20 ) n = 20;
- if( n>30 ) n = 30;
- if( !isWysiwyg ){
- /* Traditional markup-only editing */
- char *zPlaceholder = 0;
- switch( eType ){
- case WIKITYPE_NORMAL: {
- zPlaceholder = mprintf("Enter text for wiki page %s", zPageName);
- break;
- }
- case WIKITYPE_BRANCH: {
- zPlaceholder = mprintf("Enter notes about branch %s", zPageName+7);
- break;
- }
- case WIKITYPE_CHECKIN: {
- zPlaceholder = mprintf("Enter notes about check-in %.20s", zPageName+8);
- break;
- }
- case WIKITYPE_TAG: {
- zPlaceholder = mprintf("Enter notes about tag %s", zPageName+4);
- break;
- }
- }
- form_begin(0, "%R/wikiedit");
- @ %z(href("%R/markup_help"))Markup style:
- mimetype_option_menu(zMimetype);
- @
- @
- fossil_free(zPlaceholder);
- if( db_get_boolean("wysiwyg-wiki", 0) ){
- @
- }
- @
- }else{
- /* Wysiwyg editing */
- Blob html, temp;
- havePreview = 1;
- form_begin("", "%R/wikiedit");
- @
- @
- blob_zero(&temp);
- wiki_convert(&wiki, &temp, 0);
- blob_zero(&html);
- htmlTidy(blob_str(&temp), &html);
- blob_reset(&temp);
- wysiwygEditor("w", blob_str(&html), 60, n);
- blob_reset(&html);
- @
- @
- }
- login_insert_csrf_secret();
- if( havePreview ){
- if( isWysiwyg || zBody[0] ){
- @
- }else{
- @
- }
- }
- @
- @
- @
- captcha_generate(0);
- @
- manifest_destroy(pWiki);
- blob_reset(&wiki);
+ }
+ style_header("Wiki Editor");
+
+ /* Status bar */
+ CX("
"
+ "Status messages will go here.
\n"
+ /* will be moved into the tab container via JS */);
+
+ CX("
Editing: (no file loaded)
");
+
+ /* Main tab container... */
+ CX("
Loading...
");
+ /* The .hidden class on the following tab elements is to help lessen
+ the FOUC effect of the tabs before JS re-assembles them. */
+
+ /******* Page list *******/
+ {
+ CX("
");
+ CX("
Loading wiki pages list...
");
+ CX("
"/*#wikiedit-tab-pages*/);
+ }
+
+ /******* Content tab *******/
+ {
+ CX("
");
+ CX("
");
+ CX(""
+ "");
+ mimetype_option_menu(0);
+ CX("");
+ style_select_list_int("select-font-size",
+ "editor_font_size", "Editor font size",
+ NULL/*tooltip*/,
+ 100,
+ "100%", 100, "125%", 125,
+ "150%", 150, "175%", 175,
+ "200%", 200, NULL);
+ CX("");
+ CX(""/*will get moved around dynamically*/);
+ CX("");
+ CX("
");
+ CX("
");
+ CX("");
+ CX("
"/*textarea wrapper*/);
+ CX("
"/*#tab-file-content*/);
+ }
+ /****** Preview tab ******/
+ {
+ CX("
");
+ CX("
");
+ CX("");
+ /* Toggle auto-update of preview when the Preview tab is selected. */
+ style_labeled_checkbox("cb-preview-autoupdate",
+ NULL,
+ "Auto-refresh?",
+ "1", 1,
+ "If on, the preview will automatically "
+ "refresh when this tab is selected.");
+ CX("");
+ CX("
"/*.wikiedit-options*/);
+ CX("
");
+ CX("
"/*#wikiedit-tab-preview*/);
+ }
+
+ /****** Diff tab ******/
+ {
+ CX("
");
+
+ CX("
");
+ CX(""
+ "");
+ CX("");
+ CX("
");
+ CX("
"
+ "Diffs will be shown here."
+ "
");
+ CX("
"/*#wikiedit-tab-diff*/);
+ }
+
+ /****** The obligatory "Misc" tab ******/
+ {
+ CX("
");
+ CX("
Wiki formatting rules
");
+ CX("
");
+ CX("
Attachments
");
+ CX("
"
+ /* Filled out by JS */);
+ CX("
The \"Sandbox\" Page
");
+ CX("
The page named \"Sandbox\" is not a real wiki page. "
+ "It provides a place where users may test out wiki syntax "
+ "without having to actually save anything, nor pollute "
+ "the repo with endless test runs. Any attempt to save the "
+ "sandbox page will fail.
");
+ CX("
Wiki Name Rules
");
+ well_formed_wiki_name_rules();
+ CX("
"/*#wikiedit-tab-save*/);
+ }
+
+ builtin_request_js("sbsdiff.js");
+ style_emit_fossil_js_apis(0, "fetch", "dom", "tabs", "confirmer",
+ "storage", "page.wikiedit", 0);
+ builtin_fulfill_js_requests();
+ /* Dynamically populate the editor... */
+ style_emit_script_tag(0,0);
+ CX("\nfossil.onPageLoad(function(){\n");
+ CX("const P = fossil.page;\n"
+ "try{\n");
+ if(!found && zPageName && *zPageName){
+ /* For a new page, stick a dummy entry in the JS-side stash
+ and "load" it from there. */
+ CX("const winfo = {"
+ "\"name\": %!j, \"mimetype\": %!j, "
+ "\"type\": %!j, "
+ "\"parent\": null, \"version\": null"
+ "};\n",
+ zPageName,
+ zMimetype ? zMimetype : "text/x-fossil-wiki",
+ wiki_page_type_name(zPageName));
+ /* If the JS-side stash already has this page, load that
+ copy from the stash, otherwise inject a new stash entry
+ for it and load *that* one... */
+ CX("if(!P.$stash.getWinfo(winfo)){"
+ "P.$stash.updateWinfo(winfo,'');"
+ "}\n");
+ }
+ if(zPageName && *zPageName){
+ CX("P.loadPage(%!j);\n", zPageName);
+ }
+ CX("}catch(e){"
+ "fossil.error(e); console.error('Exception:',e);"
+ "}\n");
+ CX("});\n"/*fossil.onPageLoad()*/);
+ style_emit_script_tag(1,0);
style_footer();
}
/*
** WEBPAGE: wikinew
@@ -851,17 +1295,11 @@
return;
}
zName = PD("name","");
zMimetype = wiki_filter_mimetypes(P("mimetype"));
if( zName[0] && wiki_name_is_wellformed((const unsigned char *)zName) ){
- if( fossil_strcmp(zMimetype,"text/x-fossil-wiki")==0
- && db_get_boolean("wysiwyg-wiki", 0)
- ){
- cgi_redirectf("wikiedit?name=%T&wysiwyg=1", zName);
- }else{
- cgi_redirectf("wikiedit?name=%T&mimetype=%s", zName, zMimetype);
- }
+ cgi_redirectf("wikiedit?name=%T&mimetype=%s", zName, zMimetype);
}
style_header("Create A New Wiki Page");
wiki_standard_submenu(W_ALL_BUT(W_NEW));
@
Rules for wiki page names:
well_formed_wiki_name_rules();
@@ -1441,11 +1879,11 @@
** -M|--mimetype TEXT-FORMAT The mime type of the update.
** Defaults to the type used by
** the previous version of the
** page, or text/x-fossil-wiki.
** Valid values are: text/x-fossil-wiki,
-** text/markdown and text/plain. fossil,
+** text/x-markdown and text/plain. fossil,
** markdown or plain can be specified as
** synonyms of these values.
** -t|--technote DATETIME Specifies the timestamp of
** the technote to be created or
** updated. When updating a tech note
DELETED src/wysiwyg.c
Index: src/wysiwyg.c
==================================================================
--- src/wysiwyg.c
+++ /dev/null
@@ -1,328 +0,0 @@
-/*
-** Copyright (c) 2012 D. Richard Hipp
-**
-** This program is free software; you can redistribute it and/or
-** modify it under the terms of the Simplified BSD License (also
-** known as the "2-Clause License" or "FreeBSD License".)
-**
-** This program is distributed in the hope that it will be useful,
-** but without any warranty; without even the implied warranty of
-** merchantability or fitness for a particular purpose.
-**
-** Author contact information:
-** drh@hwaci.com
-** http://www.hwaci.com/drh/
-**
-*******************************************************************************
-**
-** This file contains code that generates WYSIWYG text editors on
-** web pages.
-*/
-#include "config.h"
-#include
-#include
-#include "wysiwyg.h"
-
-
-/*
-** Output code for a WYSIWYG editor. The caller must have already generated
-** the after this routine returns. The caller must include
-** an onsubmit= attribute on the