From e4ddcd93e291047717843a61d5511719fbec3901 Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:03:59 +0100 Subject: First attempt to saving tags to db --- trunk/etherpad/src/etherpad/pad/model.js | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/trunk/etherpad/src/etherpad/pad/model.js b/trunk/etherpad/src/etherpad/pad/model.js index 9424f10..ea0d68d 100644 --- a/trunk/etherpad/src/etherpad/pad/model.js +++ b/trunk/etherpad/src/etherpad/pad/model.js @@ -258,6 +258,42 @@ function accessPadGlobal(padId, padFunc, rwMode) { delete meta2.status; sqlbase.putJSON("PAD_META", padId, meta2); + /* Update tags for the pad. Should maybe be in a separate function? */ + var new_tags = pad.text().match(new RegExp("#[^,# \t\n\r][^,# \t\n\r]*", "g")) + if (new_tags == null) new_tags = new Array(); + for (i = 0; i < new_tags.length; i++) + new_tags[i] = new_tags[i].substring(1); + var new_tags_str = new_tags.join('#') + + var old_tags_row = sqlobj.selectSingle("PAD_TAG_CACHE", { id: padId }); + var old_tags_str; + if (old_tags_row !== null) + old_tags_str = old_tags_row['TAGS']; + else + old_tags_str = ''; + + var old_tags = old_tags_str != '' ? old_tags_str.split('#') : new Array(); + + if (new_tags_str != old_tags_str) { + log.info({message: 'Updating tags', new_tags:new_tags, old_tags:old_tags}); + + if (old_tags_row) + sqlobj.update("PAD_TAG_CACHE", { id: padId }, {tags: new_tags.join('#')}); + else + sqlobj.insert("PAD_TAG_CACHE", {id: padId, tags: new_tags.join('#')}); + + sqlobj.deleteRows("PAD_TAG", {pad_id: padId}); + + for (i = 0; i < new_tags.length; i++) { + var tag_row = sqlobj.selectSingle("TAG", { name: new_tags[i] }); + if (tag_row === null) { + sqlobj.insert("TAG", {name: new_tags[i]}); + tag_row = sqlobj.selectSingle("TAG", { name: new_tags[i] }); + } + sqlobj.insert("PAD_TAG", {pad_id: padId, tag_id: tag_row['ID']}); + } + } + _getPadStringArray(padId, "revs").writeToDB(); _getPadStringArray(padId, "revs10").writeToDB(); _getPadStringArray(padId, "revs100").writeToDB(); -- cgit v1.2.3-1-g7c22 From 648f6a226b076424e9df533107a5c0cf1804293a Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:08:28 +0100 Subject: Oups, last commit committed the tagging support, but missed this file --- .../etherpad/db_migrations/m0038_pad_tag_tables.js | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 trunk/etherpad/src/etherpad/db_migrations/m0038_pad_tag_tables.js diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0038_pad_tag_tables.js b/trunk/etherpad/src/etherpad/db_migrations/m0038_pad_tag_tables.js new file mode 100644 index 0000000..69ae6e4 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0038_pad_tag_tables.js @@ -0,0 +1,35 @@ +/** + * Copyright 2009 RedHog, Egil Möller . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); + +function run() { + sqlobj.createTable('TAG', { + ID: 'int not null '+sqlcommon.autoIncrementClause()+' primary key', + NAME: 'varchar(128) character set utf8 collate utf8_bin not null', + }); + + sqlobj.createTable('PAD_TAG', { + PAD_ID: 'varchar(128) character set utf8 collate utf8_bin not null references PAD_META(ID)', + TAG_ID: 'int default NULL references TAG(ID)', + }); + + sqlobj.createTable('PAD_TAG_CACHE', { + PAD_ID: 'varchar(128) character set utf8 collate utf8_bin unique not null references PAD_META(ID)', + TAGS: 'varchar(1024) collate utf8_bin not null', + }); +} -- cgit v1.2.3-1-g7c22 From 16b37f7a1f9ff8e559e90261c8f6cd3da46dae49 Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:13:39 +0100 Subject: Tag search works fully but looks awfull --- .../src/etherpad/control/tag/tag_control.js | 231 +++++++++++++++++++++ trunk/etherpad/src/etherpad/pad/model.js | 2 +- trunk/etherpad/src/main.js | 2 + trunk/etherpad/src/static/js/pad_utils.js | 24 +++ trunk/etherpad/src/templates/misc/pad_default.ejs | 3 + trunk/etherpad/src/templates/tag/tag_search.ejs | 109 ++++++++++ .../framework-src/modules/sqlbase/sqlobj.js | 50 ++++- 7 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 trunk/etherpad/src/etherpad/control/tag/tag_control.js create mode 100644 trunk/etherpad/src/templates/tag/tag_search.ejs diff --git a/trunk/etherpad/src/etherpad/control/tag/tag_control.js b/trunk/etherpad/src/etherpad/control/tag/tag_control.js new file mode 100644 index 0000000..e56ee8e --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/tag/tag_control.js @@ -0,0 +1,231 @@ +/** + * Copyright 2009 RedHog, Egil Möller + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("faststatic"); +import("dispatch.{Dispatcher,PrefixMatcher,forward}"); + +import("etherpad.utils.*"); +import("etherpad.globals.*"); +import("etherpad.log"); +import("etherpad.pad.padusers"); +import("etherpad.pro.pro_utils"); +import("etherpad.helpers"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); +import("sqlbase.sqlbase"); +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); + +function tagsToQuery(tags, antiTags) { + var prefixed = []; + for (i = 0; i < antiTags.length; i++) + prefixed[i] = '!' + antiTags[i]; + return tags.concat(prefixed).join(','); +} + +function stringFormat(text, obj) { + var name; + for (name in obj) { + //iterate through the params and replace their placeholders from the original text + text = text.replace(new RegExp('%\\(' + name + '\\)s', 'gi' ), obj[name]); + } + return text; +} + +function getQueryToSql(tags, antiTags, querySql) { + var queryTable; + var queryParams; + + if (querySql == null) { + queryTable = 'PAD_META'; + queryParams = []; + } else { + queryTable = querySql.sql; + queryParams = querySql.params; + } + + var exceptArray = []; + var joinArray = []; + var exceptParamArray = []; + var joinParamArray = []; + + var info = new Object(); + info.queryTable = queryTable; + info.n = 0; + var i; + + for (i = 0; i < antiTags.length; i++) { + tag = antiTags[i]; + exceptArray.push(stringFormat('' + + 'except ' + + ' select ' + + ' pt%(n)s.PAD_ID ' + + ' from ' + + ' PAD_TAG as pt%(n)s, ' + + ' TAG as t%(n)s ' + + ' where ' + + ' t%(n)s.ID = pt%(n)s.TAG_ID ' + + ' and t%(n)s.NAME = ? ' + + '', info)); + exceptParamArray.push(tag); + info.n += 1; + } + for (i = 0; i < tags.length; i++) { + tag = tags[i]; + joinArray.push(stringFormat('' + + 'join PAD_TAG as pt%(n)s on ' + + ' pt%(n)s.PAD_ID = p.ID ' + + 'join TAG as t%(n)s on ' + + ' t%(n)s.ID = pt%(n)s.TAG_ID ' + + ' and t%(n)s.NAME = ? ' + + '', info)); + joinParamArray.push(tag); + info.n += 1; + } + + info["joins"] = joinArray.join(""); + info["excepts"] = exceptArray.join(""); + + return { + sql: stringFormat('' + + '(select ' + + ' p.ID ' + + ' from ' + + ' %(queryTable)s as p ' + + ' %(joins)s ' + + ' %(excepts)s ' + + ') ' + + '', info), + params: queryParams.concat(joinParamArray).concat(exceptParamArray)}; +} + +function nrSql(querySql) { + var queryTable; + var queryParams; + + if (querySql == null) { + queryTable = 'PAD_META'; + queryParams = []; + } else { + queryTable = querySql.sql; + queryParams = querySql.params; + } + + var info = []; + info['query_sql'] = queryTable + return { + sql: stringFormat('(select count(*) as total from %(query_sql)s as q)', info), + params: queryParams}; +} + +function newTagsSql(querySql) { + var queryTable; + var queryParams; + + if (querySql == null) { + queryTable = 'PAD_META'; + queryParams = []; + } else { + queryTable = querySql.sql; + queryParams = querySql.params; + } + + var info = []; + info["query_post_table"] = queryTable; + var queryNrSql = nrSql(querySql); + info["query_nr_sql"] = queryNrSql.sql; + queryNrParams = queryNrSql.params; + + return { + sql: stringFormat('' + + 'select ' + + ' t.NAME tagname, ' + + ' count(tp.PAD_ID) as matches, ' + + ' tn.total - count(tp.PAD_ID) as antimatches, ' + + ' abs(count(tp.PAD_ID) - (tn.total / 2)) as weight ' + + 'from ' + + ' TAG as t, ' + + ' PAD_TAG as tp, ' + + ' %(query_nr_sql)s as tn ' + + 'where ' + + ' tp.TAG_ID = t.ID ' + + ' and tp.PAD_ID in %(query_post_table)s ' + + 'group by t.NAME, tn.total ' + + 'having ' + + ' count(tp.PAD_ID) > 0 and count(tp.PAD_ID) < tn.total ' + + 'order by ' + + ' abs(count(tp.PAD_ID) - (tn.total / 2)) asc ' + + 'limit 10 ' + + '', info), + params: queryNrParams.concat(queryParams)}; +} + + +function onRequest() { + var tags = new Array(); + var antiTags = new Array(); + + if (request.params.query != undefined) { + var query = request.params.query.split(','); + for (i = 0; i < query.length; i++) + if (query[i][0] == '!') + antiTags.push(query[i].substring(1)); + else + tags.push(query[i]); + } + + var querySql = getQueryToSql(tags.concat(['public']), antiTags); + + var queryNewTagsSql = newTagsSql(querySql); + var newTags = sqlobj.executeRaw(queryNewTagsSql.sql, queryNewTagsSql.params); + + var matchingPads; + if (tags.length > 0 || antiTags.length > 0) { + var sql = "select p.ID from PAD_META as p, " + querySql.sql + " as q where p.ID = q.ID limit 10" + matchingPads = sqlobj.executeRaw(sql, querySql.params); + } else { + matchingPads = []; + } + + var isPro = pro_utils.isProDomainRequest(); + var userId = padusers.getUserId(); + + helpers.addClientVars({ + userAgent: request.headers["User-Agent"], + debugEnabled: request.params.djs, + clientIp: request.clientAddr, + colorPalette: COLOR_PALETTE, + serverTimestamp: +(new Date), + isProPad: isPro, + userIsGuest: padusers.isGuest(userId), + userId: userId, + }); + + var isProUser = (isPro && ! padusers.isGuest(userId)); + + renderHtml("tag/tag_search.ejs", + { + tagsToQuery: tagsToQuery, + tags: tags, + antiTags: antiTags, + newTags: newTags, + matchingPads: matchingPads, + bodyClass: 'nonpropad', + isPro: isPro, + isProAccountHolder: isProUser, + account: getSessionProAccount(), // may be falsy + }); + return true; +} diff --git a/trunk/etherpad/src/etherpad/pad/model.js b/trunk/etherpad/src/etherpad/pad/model.js index ea0d68d..c557a2c 100644 --- a/trunk/etherpad/src/etherpad/pad/model.js +++ b/trunk/etherpad/src/etherpad/pad/model.js @@ -259,7 +259,7 @@ function accessPadGlobal(padId, padFunc, rwMode) { sqlbase.putJSON("PAD_META", padId, meta2); /* Update tags for the pad. Should maybe be in a separate function? */ - var new_tags = pad.text().match(new RegExp("#[^,# \t\n\r][^,# \t\n\r]*", "g")) + var new_tags = pad.text().match(new RegExp("#[^,#!\\s][^,#!\\s]*", "g")); if (new_tags == null) new_tags = new Array(); for (i = 0; i < new_tags.length; i++) new_tags[i] = new_tags[i].substring(1); diff --git a/trunk/etherpad/src/main.js b/trunk/etherpad/src/main.js index 9cc1db2..61e365f 100644 --- a/trunk/etherpad/src/main.js +++ b/trunk/etherpad/src/main.js @@ -42,6 +42,7 @@ import("etherpad.control.historycontrol"); import("etherpad.control.loadtestcontrol"); import("etherpad.control.maincontrol"); import("etherpad.control.pad.pad_control"); +import("etherpad.control.tag.tag_control"); import("etherpad.control.pne_manual_control"); import("etherpad.control.pne_tracker_control"); import("etherpad.control.pro.admin.license_manager_control"); @@ -362,6 +363,7 @@ function handlePath() { [PrefixMatcher('/static/'), forward(static_control)], [PrefixMatcher('/ep/genimg/'), genimg.renderPath], [PrefixMatcher('/ep/pad/'), forward(pad_control)], + [PrefixMatcher('/ep/tag/'), forward(tag_control)], [PrefixMatcher('/ep/script/'), forward(scriptcontrol)], [/^\/([^\/]+)$/, pad_control.render_pad], [DirMatcher('/ep/unit-tests/'), forward(testcontrol)], diff --git a/trunk/etherpad/src/static/js/pad_utils.js b/trunk/etherpad/src/static/js/pad_utils.js index de606ad..ccedac9 100644 --- a/trunk/etherpad/src/static/js/pad_utils.js +++ b/trunk/etherpad/src/static/js/pad_utils.js @@ -106,6 +106,18 @@ var padutils = { var hourmin = d.getHours()+":"+("0"+d.getMinutes()).slice(-2); return dayOfWeek+' '+month+' '+dayOfMonth+' '+year+' '+hourmin; }, + findTags: function(text) { + var tagExp = new RegExp("#[^,#!\\s][^,#!\\s]*", "g"); + var tags = null; + var execResult; + while ((execResult = tagExp.exec(text))) { + tags = (tags || []); + var startIndex = execResult.index; + var url = execResult[0]; + tags.push([startIndex, url]); + } + return tags; + }, findURLs: function(text) { // copied from ACE var _REGEX_WORDCHAR = /[\u0030-\u0039\u0041-\u005A\u0061-\u007A\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF\u0100-\u1FFF\u3040-\u9FFF\uF900-\uFDFF\uFE70-\uFEFE\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFDC]/; @@ -133,6 +145,7 @@ var padutils = { var idx = 0; var pieces = []; var urls = padutils.findURLs(text); + var tags = padutils.findTags(text); function advanceTo(i) { if (i > idx) { pieces.push(padutils.escapeHtml(text.substring(idx, i))); @@ -150,6 +163,17 @@ var padutils = { pieces.push(''); } } + if (tags) { + for(var j=0;j'); + advanceTo(startIndex + href.length); + pieces.push(''); + } + } advanceTo(text.length); return pieces.join(''); }, diff --git a/trunk/etherpad/src/templates/misc/pad_default.ejs b/trunk/etherpad/src/templates/misc/pad_default.ejs index 96b7e25..2287096 100644 --- a/trunk/etherpad/src/templates/misc/pad_default.ejs +++ b/trunk/etherpad/src/templates/misc/pad_default.ejs @@ -14,3 +14,6 @@ limitations under the License. */ %> Welcome to EtherPad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! + +You can make this pad public by tagging it with #public, and categorize it using twitter-style tags, like this: #newpage #uncategorized +If you don't want this pad to be public, just delete the above tags :) diff --git a/trunk/etherpad/src/templates/tag/tag_search.ejs b/trunk/etherpad/src/templates/tag/tag_search.ejs new file mode 100644 index 0000000..d7fe351 --- /dev/null +++ b/trunk/etherpad/src/templates/tag/tag_search.ejs @@ -0,0 +1,109 @@ +<% /* Copyright 2009 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. */ %> +<% + helpers.setHtmlTitle("Browse tags"); + helpers.setBodyId("padbody"); + helpers.addBodyClass("limwidth nonpropad nonprouser"); + helpers.includeCss("pad2_ejs.css"); + helpers.setRobotsPolicy({index: false, follow: false}) + helpers.includeJQuery(); + helpers.includeCometJs(); + helpers.includeJs("json2.js"); + helpers.addToHead('\n\n'); +%> + +
+
+
+
+
+ + <% if (isProAccountHolder) { %> +
<%= toHTML(account.email) %>(sign out)
+ <% } else if (isPro) { %> + + <% } %> +
+
+
+
+
Browse tags
+ + + + + + +
+
+
+ +
+
+ XYZZY +
+ +
+ +
NANANANA
+
+ +
+
+
+
+ +
+   +   +   +   +   +   +   +   +   +   +   +
+
+
+
+

Refine your query

+

Search for pads that have a tag

+ <% for (i = 0; i < newTags.length; i++) { %> + <%= newTags[i].tagname %> + <% } %> + +

Search for pads that doesn't have a tag

+ <% for (i = 0; i < newTags.length; i++) { %> + <%= newTags[i].tagname %> + <% } %> + +

Matching pads

+ +
+
+
+ +
+
+
+
+
+ diff --git a/trunk/infrastructure/framework-src/modules/sqlbase/sqlobj.js b/trunk/infrastructure/framework-src/modules/sqlbase/sqlobj.js index 4bc1263..e599c92 100644 --- a/trunk/infrastructure/framework-src/modules/sqlbase/sqlobj.js +++ b/trunk/infrastructure/framework-src/modules/sqlbase/sqlobj.js @@ -17,6 +17,7 @@ import("cache_utils.syncedWithCache"); import("sqlbase.sqlcommon.*"); import("jsutils.*"); +import("etherpad.log"); jimport("java.lang.System.out.println"); jimport("java.sql.Statement"); @@ -112,10 +113,13 @@ function _getJsValFromResultSet(rs, type, colName) { } else { r = null; } - } else if (type == java.sql.Types.INTEGER || + } else if (type == java.sql.Types.BIGINT || + type == java.sql.Types.INTEGER || type == java.sql.Types.SMALLINT || type == java.sql.Types.TINYINT) { r = rs.getInt(colName); + } else if (type == java.sql.Types.DECIMAL) { + r = rs.getFloat(colName); } else if (type == java.sql.Types.BIT) { r = rs.getBoolean(colName); } else { @@ -192,8 +196,9 @@ function _resultRowToJsObj(resultSet) { var metaData = resultSet.getMetaData(); var colCount = metaData.getColumnCount(); + for (var i = 1; i <= colCount; i++) { - var colName = metaData.getColumnName(i); + var colName = metaData.getColumnLabel(i); var type = metaData.getColumnType(i); resultObj[colName] = _getJsValFromResultSet(resultSet, type, colName); } @@ -338,6 +343,47 @@ function selectMulti(tableName, constraints, options) { }); } +function executeRaw(stmnt, params) { + return withConnection(function(conn) { + var pstmnt = conn.prepareStatement(stmnt); + return closing(pstmnt, function() { + for (var i = 0; i < params.length; i++) { + var v = params[i]; + + if (v === undefined) { + throw Error("value is undefined for key "+i); + } + + if (typeof(v) == 'object' && v.isnull) { + pstmnt.setNull(i+1, v.type); + } else if (typeof(v) == 'string') { + pstmnt.setString(i+1, v); + } else if (typeof(v) == 'number') { + pstmnt.setInt(i+1, v); + } else if (typeof(v) == 'boolean') { + pstmnt.setBoolean(i+1, v); + } else if (v.valueOf && v.getDate && v.getHours) { + pstmnt.setTimestamp(i+1, new java.sql.Timestamp(+v)); + } else { + throw Error("Cannot insert this type of javascript object: "+typeof(v)+" (key="+i+", value = "+v+")"); + } + } + + _qdebug(stmnt); + var resultSet = pstmnt.executeQuery(); + var resultArray = []; + + return closing(resultSet, function() { + while (resultSet.next()) { + resultArray.push(_resultRowToJsObj(resultSet)); + } + + return resultArray; + }); + }); + }); +} + /* returns number of rows updated */ function update(tableName, constraints, obj) { var objKeys = keys(obj); -- cgit v1.2.3-1-g7c22 From d7578daaf1aeb7c0db0d2cc069f67b870f8bd3e7 Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:16:39 +0100 Subject: Bugfix for mysql support for anti_tags - I forgot that mysql doesn't support the except sql clause --- .../src/etherpad/control/tag/tag_control.js | 49 ++++++++++++---------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/trunk/etherpad/src/etherpad/control/tag/tag_control.js b/trunk/etherpad/src/etherpad/control/tag/tag_control.js index e56ee8e..3fe680c 100644 --- a/trunk/etherpad/src/etherpad/control/tag/tag_control.js +++ b/trunk/etherpad/src/etherpad/control/tag/tag_control.js @@ -58,6 +58,7 @@ function getQueryToSql(tags, antiTags, querySql) { var exceptArray = []; var joinArray = []; + var whereArray = []; var exceptParamArray = []; var joinParamArray = []; @@ -68,46 +69,47 @@ function getQueryToSql(tags, antiTags, querySql) { for (i = 0; i < antiTags.length; i++) { tag = antiTags[i]; - exceptArray.push(stringFormat('' + - 'except ' + - ' select ' + - ' pt%(n)s.PAD_ID ' + - ' from ' + - ' PAD_TAG as pt%(n)s, ' + - ' TAG as t%(n)s ' + - ' where ' + - ' t%(n)s.ID = pt%(n)s.TAG_ID ' + - ' and t%(n)s.NAME = ? ' + - '', info)); + exceptArray.push( + stringFormat( + 'left join (PAD_TAG as pt%(n)s ' + + ' join TAG AS t%(n)s on ' + + ' t%(n)s.NAME = ? ' + + ' and t%(n)s.ID = pt%(n)s.TAG_ID) on ' + + ' pt%(n)s.PAD_ID = p.ID', + info)); + whereArray.push(stringFormat('pt%(n)s.TAG_ID is null', info)); exceptParamArray.push(tag); info.n += 1; } for (i = 0; i < tags.length; i++) { tag = tags[i]; - joinArray.push(stringFormat('' + - 'join PAD_TAG as pt%(n)s on ' + - ' pt%(n)s.PAD_ID = p.ID ' + - 'join TAG as t%(n)s on ' + - ' t%(n)s.ID = pt%(n)s.TAG_ID ' + - ' and t%(n)s.NAME = ? ' + - '', info)); + joinArray.push( + stringFormat( + 'join PAD_TAG as pt%(n)s on ' + + ' pt%(n)s.PAD_ID = p.ID ' + + 'join TAG as t%(n)s on ' + + ' t%(n)s.ID = pt%(n)s.TAG_ID ' + + ' and t%(n)s.NAME = ? ', + info)); joinParamArray.push(tag); info.n += 1; } info["joins"] = joinArray.join(""); info["excepts"] = exceptArray.join(""); - + info["wheres"] = whereArray.length > 0 ? ' where ' + whereArray.join(' ') : ''; + return { - sql: stringFormat('' + + sql: stringFormat( '(select ' + ' p.ID ' + ' from ' + ' %(queryTable)s as p ' + ' %(joins)s ' + - ' %(excepts)s ' + - ') ' + - '', info), + ' %(excepts)s ' + + ' %(wheres)s ' + + ') ', + info), params: queryParams.concat(joinParamArray).concat(exceptParamArray)}; } @@ -187,6 +189,7 @@ function onRequest() { } var querySql = getQueryToSql(tags.concat(['public']), antiTags); + log.info(querySql.sql); var queryNewTagsSql = newTagsSql(querySql); var newTags = sqlobj.executeRaw(queryNewTagsSql.sql, queryNewTagsSql.params); -- cgit v1.2.3-1-g7c22 From 2d8d9afeb01df0772661348321974627d42c8850 Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:17:30 +0100 Subject: Readonly public pads --- .../etherpad/src/etherpad/control/tag/tag_control.js | 9 ++++++++- trunk/etherpad/src/templates/tag/tag_search.ejs | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/trunk/etherpad/src/etherpad/control/tag/tag_control.js b/trunk/etherpad/src/etherpad/control/tag/tag_control.js index 3fe680c..d902b56 100644 --- a/trunk/etherpad/src/etherpad/control/tag/tag_control.js +++ b/trunk/etherpad/src/etherpad/control/tag/tag_control.js @@ -18,6 +18,7 @@ import("faststatic"); import("dispatch.{Dispatcher,PrefixMatcher,forward}"); import("etherpad.utils.*"); +import("etherpad.collab.server_utils"); import("etherpad.globals.*"); import("etherpad.log"); import("etherpad.pad.padusers"); @@ -196,12 +197,17 @@ function onRequest() { var matchingPads; if (tags.length > 0 || antiTags.length > 0) { - var sql = "select p.ID from PAD_META as p, " + querySql.sql + " as q where p.ID = q.ID limit 10" + var sql = "select p.ID, p.TAGS from PAD_TAG_CACHE as p, " + querySql.sql + " as q where p.ID = q.ID limit 10" matchingPads = sqlobj.executeRaw(sql, querySql.params); } else { matchingPads = []; } + for (i = 0; i < matchingPads.length; i++) { + matchingPads[i].TAGS = matchingPads[i].TAGS.split('#'); + } +log.info({pads:matchingPads}); + var isPro = pro_utils.isProDomainRequest(); var userId = padusers.getUserId(); @@ -221,6 +227,7 @@ function onRequest() { renderHtml("tag/tag_search.ejs", { tagsToQuery: tagsToQuery, + padIdToReadonly: server_utils.padIdToReadonly, tags: tags, antiTags: antiTags, newTags: newTags, diff --git a/trunk/etherpad/src/templates/tag/tag_search.ejs b/trunk/etherpad/src/templates/tag/tag_search.ejs index d7fe351..18e88e1 100644 --- a/trunk/etherpad/src/templates/tag/tag_search.ejs +++ b/trunk/etherpad/src/templates/tag/tag_search.ejs @@ -21,6 +21,13 @@ limitations under the License. */ %> helpers.includeCometJs(); helpers.includeJs("json2.js"); helpers.addToHead('\n\n'); + + function inArray(item, arr) { + for (var i = 0; i < arr.length; i++) + if (arr[i] == item) + return true; + return false; + } %>
@@ -94,7 +101,17 @@ limitations under the License. */ %>

Matching pads

    <% for (i = 0; i < matchingPads.length; i++) { %> -
  • <%= matchingPads[i].ID %>
  • + <% + var matchingPadId = matchingPads[i].ID; + var matchingPadUrl = matchingPadId; + if (!inArray('writable', matchingPads[i].TAGS)) { + matchingPadId = padIdToReadonly(matchingPads[i].ID); + matchingPadUrl = 'ep/pad/view/' + matchingPadId + '/latest'; + } + %> +
  • +

    <%= matchingPadId %>

    +

  • <% } %>
-- cgit v1.2.3-1-g7c22 From faae6d36cc92aae7c040146edb8471fa8e8c6a75 Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:25:15 +0100 Subject: Tags are now shown as links --- trunk/infrastructure/ace/bin/make | 8 ++-- trunk/infrastructure/ace/www/ace2_inner.js | 1 + trunk/infrastructure/ace/www/domline.js | 6 +++ trunk/infrastructure/ace/www/linestylefilter.js | 50 +++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/trunk/infrastructure/ace/bin/make b/trunk/infrastructure/ace/bin/make index dad11ff..0a39d91 100755 --- a/trunk/infrastructure/ace/bin/make +++ b/trunk/infrastructure/ace/bin/make @@ -263,15 +263,15 @@ def doMake { def copyFileToEtherpad(fromName: String, toName: String) { var code = getFile(srcDir+"/"+fromName); - code = replaceFirstLine(code, "// DO NOT EDIT THIS FILE, edit "+ - "infrastructure/ace/www/"+fromName); + code = "// DO NOT EDIT THIS FILE, edit "+ + "infrastructure/ace/www/"+fromName+"\n"+code; code = code.replaceAll("""(?<=\n)\s*//\s*%APPJET%:\s*""", ""); putFile(code, "../../etherpad/src/etherpad/collab/ace/"+toName); } def copyFileToClientSide(fromName: String, toName: String) { var code = getFile(srcDir+"/"+fromName); - code = replaceFirstLine(code, "// DO NOT EDIT THIS FILE, edit "+ - "infrastructure/ace/www/"+fromName); + code = "// DO NOT EDIT THIS FILE, edit "+ + "infrastructure/ace/www/"+fromName+"\n"+code; code = code.replaceAll("""(?<=\n)\s*//\s*%APPJET%:.*?\n""", ""); code = code.replaceAll("""(?<=\n)\s*//\s*%CLIENT FILE ENDS HERE%[\s\S]*""", ""); diff --git a/trunk/infrastructure/ace/www/ace2_inner.js b/trunk/infrastructure/ace/www/ace2_inner.js index afd1e35..eccb53c 100644 --- a/trunk/infrastructure/ace/www/ace2_inner.js +++ b/trunk/infrastructure/ace/www/ace2_inner.js @@ -1140,6 +1140,7 @@ function OUTER(gscope) { else { var offsetIntoLine = 0; var filteredFunc = textAndClassFunc; + filteredFunc = linestylefilter.getPadTagFilter(text, filteredFunc); filteredFunc = linestylefilter.getURLFilter(text, filteredFunc); if (browser.msie) { // IE7+ will take an e-mail address like and linkify it to foo@bar.com. diff --git a/trunk/infrastructure/ace/www/domline.js b/trunk/infrastructure/ace/www/domline.js index 70f86cc..e8a9ba7 100644 --- a/trunk/infrastructure/ace/www/domline.js +++ b/trunk/infrastructure/ace/www/domline.js @@ -85,6 +85,12 @@ domline.createDomLine = function(nonEmpty, doesWrap, optBrowser, optDocument) { return space+"url"; }); } + if (cls.indexOf('padtag') >= 0) { + cls = cls.replace(/(^| )padtag:(\S+)/g, function(x0, space, padtag) { + href = '/ep/tag/?query=' + padtag; + return space+"padtag padtag_"+padtag; + }); + } if (cls.indexOf('tag') >= 0) { cls = cls.replace(/(^| )tag:(\S+)/g, function(x0, space, tag) { if (! simpleTags) simpleTags = []; diff --git a/trunk/infrastructure/ace/www/linestylefilter.js b/trunk/infrastructure/ace/www/linestylefilter.js index 0ac578b..8c7c396 100644 --- a/trunk/infrastructure/ace/www/linestylefilter.js +++ b/trunk/infrastructure/ace/www/linestylefilter.js @@ -185,6 +185,55 @@ linestylefilter.getURLFilter = function(lineText, textAndClassFunc) { splitPoints); }; +/* Copy of getURLFilter with slight modifications, should probably merge and just use different regexps */ +linestylefilter.REGEX_PADTAG = new RegExp("#[^,#!\\s][^,#!\\s]*", "g"); + +linestylefilter.getPadTagFilter = function(lineText, textAndClassFunc) { + linestylefilter.REGEX_PADTAG.lastIndex = 0; + var padTags = null; + var splitPoints = null; + var execResult; + while ((execResult = linestylefilter.REGEX_PADTAG.exec(lineText))) { + if (! padTags) { + padTags = []; + splitPoints = []; + } + var startIndex = execResult.index; + var padTag = execResult[0]; + padTags.push([startIndex, padTag]); + splitPoints.push(startIndex, startIndex + padTag.length); + } + + if (! padTags) return textAndClassFunc; + + function padTagForIndex(idx) { + for(var k=0; k= u[0] && idx < u[0]+u[1].length) { + return u[1]; + } + } + return false; + } + + var handlePadTagsAfterSplit = (function() { + var curIndex = 0; + return function(txt, cls) { + var txtlen = txt.length; + var newCls = cls; + var padTag = padTagForIndex(curIndex); + if (padTag) { + newCls += " padtag:"+padTag.substring(1); + } + textAndClassFunc(txt, newCls); + curIndex += txtlen; + }; + })(); + + return linestylefilter.textAndClassFuncSplitter(handlePadTagsAfterSplit, + splitPoints); +} + linestylefilter.textAndClassFuncSplitter = function(func, splitPointsOpt) { var nextPointIndex = 0; var idx = 0; @@ -241,6 +290,7 @@ linestylefilter.populateDomLine = function(textLine, aline, apool, var func = textAndClassFunc; func = linestylefilter.getURLFilter(text, func); + func = linestylefilter.getPadTagFilter(text, func); func = linestylefilter.getLineStyleFilter(text.length, aline, func, apool); func(text, ''); -- cgit v1.2.3-1-g7c22 From e59ca3a22a5be5dea265e58b67f93973b60f7ed9 Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:31:50 +0100 Subject: Cleaned up the taglinking code a bit --- trunk/infrastructure/ace/www/ace2_inner.js | 11 +---------- trunk/infrastructure/ace/www/linestylefilter.js | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/trunk/infrastructure/ace/www/ace2_inner.js b/trunk/infrastructure/ace/www/ace2_inner.js index eccb53c..e817c95 100644 --- a/trunk/infrastructure/ace/www/ace2_inner.js +++ b/trunk/infrastructure/ace/www/ace2_inner.js @@ -1139,16 +1139,7 @@ function OUTER(gscope) { } else { var offsetIntoLine = 0; - var filteredFunc = textAndClassFunc; - filteredFunc = linestylefilter.getPadTagFilter(text, filteredFunc); - filteredFunc = linestylefilter.getURLFilter(text, filteredFunc); - if (browser.msie) { - // IE7+ will take an e-mail address like and linkify it to foo@bar.com. - // We then normalize it back to text with no angle brackets. It's weird. So always - // break spans at an "at" sign. - filteredFunc = linestylefilter.getAtSignSplitterFilter( - text, filteredFunc); - } + var filteredFunc = linestylefilter.getFilterStack(text, textAndClassFunc, browser); var lineNum = rep.lines.indexOfEntry(lineEntry); var aline = rep.alines[lineNum]; filteredFunc = linestylefilter.getLineStyleFilter( diff --git a/trunk/infrastructure/ace/www/linestylefilter.js b/trunk/infrastructure/ace/www/linestylefilter.js index 8c7c396..f0f1343 100644 --- a/trunk/infrastructure/ace/www/linestylefilter.js +++ b/trunk/infrastructure/ace/www/linestylefilter.js @@ -275,6 +275,19 @@ linestylefilter.textAndClassFuncSplitter = function(func, splitPointsOpt) { return spanHandler; }; +linestylefilter.getFilterStack = function(lineText, textAndClassFunc, browser) { + var func = linestylefilter.getURLFilter(lineText, textAndClassFunc); + func = linestylefilter.getPadTagFilter(lineText, func); + if (browser !== undefined && browser.msie) { + // IE7+ will take an e-mail address like and linkify it to foo@bar.com. + // We then normalize it back to text with no angle brackets. It's weird. So always + // break spans at an "at" sign. + func = linestylefilter.getAtSignSplitterFilter( + lineText, func); + } + return func; +}; + // domLineObj is like that returned by domline.createDomLine linestylefilter.populateDomLine = function(textLine, aline, apool, domLineObj) { @@ -288,9 +301,7 @@ linestylefilter.populateDomLine = function(textLine, aline, apool, domLineObj.appendSpan(tokenText, tokenClass); } - var func = textAndClassFunc; - func = linestylefilter.getURLFilter(text, func); - func = linestylefilter.getPadTagFilter(text, func); + var func = linestylefilter.getFilterStack(text, textAndClassFunc); func = linestylefilter.getLineStyleFilter(text.length, aline, func, apool); func(text, ''); -- cgit v1.2.3-1-g7c22 From df45eab86aef9db943fabb43667f560bfd388f3b Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:32:38 +0100 Subject: Added a view of the current query to the search page --- trunk/etherpad/src/etherpad/control/tag/tag_control.js | 3 +-- trunk/etherpad/src/templates/tag/tag_search.ejs | 11 +++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/trunk/etherpad/src/etherpad/control/tag/tag_control.js b/trunk/etherpad/src/etherpad/control/tag/tag_control.js index d902b56..56dd50b 100644 --- a/trunk/etherpad/src/etherpad/control/tag/tag_control.js +++ b/trunk/etherpad/src/etherpad/control/tag/tag_control.js @@ -180,7 +180,7 @@ function onRequest() { var tags = new Array(); var antiTags = new Array(); - if (request.params.query != undefined) { + if (request.params.query != undefined && request.params.query != '') { var query = request.params.query.split(','); for (i = 0; i < query.length; i++) if (query[i][0] == '!') @@ -206,7 +206,6 @@ function onRequest() { for (i = 0; i < matchingPads.length; i++) { matchingPads[i].TAGS = matchingPads[i].TAGS.split('#'); } -log.info({pads:matchingPads}); var isPro = pro_utils.isProDomainRequest(); var userId = padusers.getUserId(); diff --git a/trunk/etherpad/src/templates/tag/tag_search.ejs b/trunk/etherpad/src/templates/tag/tag_search.ejs index 18e88e1..c7a1d4e 100644 --- a/trunk/etherpad/src/templates/tag/tag_search.ejs +++ b/trunk/etherpad/src/templates/tag/tag_search.ejs @@ -87,6 +87,17 @@ limitations under the License. */ %>
+

Current query

+ <% if (tags.length == 0 && antiTags.length == 0) { %> + < No current query; please select some tags below to search for pads > + <% } else { %> + <% for (i = 0; i < tags.length; i++) { %> + <%= tags[i] %> + <% } %> + <% for (i = 0; i < antiTags.length; i++) { %> + !<%= antiTags[i] %> + <% } %> + <% } %>

Refine your query

Search for pads that have a tag

<% for (i = 0; i < newTags.length; i++) { %> -- cgit v1.2.3-1-g7c22 From 1d357551227a8e58eff504220de509ec82357095 Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:33:39 +0100 Subject: Bugfix for queries with multiple negative tags --- trunk/etherpad/src/etherpad/control/tag/tag_control.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/trunk/etherpad/src/etherpad/control/tag/tag_control.js b/trunk/etherpad/src/etherpad/control/tag/tag_control.js index 56dd50b..bf9e9b2 100644 --- a/trunk/etherpad/src/etherpad/control/tag/tag_control.js +++ b/trunk/etherpad/src/etherpad/control/tag/tag_control.js @@ -76,7 +76,7 @@ function getQueryToSql(tags, antiTags, querySql) { ' join TAG AS t%(n)s on ' + ' t%(n)s.NAME = ? ' + ' and t%(n)s.ID = pt%(n)s.TAG_ID) on ' + - ' pt%(n)s.PAD_ID = p.ID', + ' pt%(n)s.PAD_ID = p.ID ', info)); whereArray.push(stringFormat('pt%(n)s.TAG_ID is null', info)); exceptParamArray.push(tag); @@ -96,9 +96,9 @@ function getQueryToSql(tags, antiTags, querySql) { info.n += 1; } - info["joins"] = joinArray.join(""); - info["excepts"] = exceptArray.join(""); - info["wheres"] = whereArray.length > 0 ? ' where ' + whereArray.join(' ') : ''; + info["joins"] = joinArray.join(' '); + info["excepts"] = exceptArray.join(' '); + info["wheres"] = whereArray.length > 0 ? ' where ' + whereArray.join(' and ') : ''; return { sql: stringFormat( -- cgit v1.2.3-1-g7c22 From 1bfcde5f269fe005e70a4eea176d71bac0f42dcb Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:34:24 +0100 Subject: List of tags for each match (could need cleanup) --- trunk/etherpad/src/templates/tag/tag_search.ejs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/trunk/etherpad/src/templates/tag/tag_search.ejs b/trunk/etherpad/src/templates/tag/tag_search.ejs index c7a1d4e..ccf38ff 100644 --- a/trunk/etherpad/src/templates/tag/tag_search.ejs +++ b/trunk/etherpad/src/templates/tag/tag_search.ejs @@ -110,7 +110,7 @@ limitations under the License. */ %> <% } %>

Matching pads

-
    +
    <% for (i = 0; i < matchingPads.length; i++) { %> <% var matchingPadId = matchingPads[i].ID; @@ -120,11 +120,14 @@ limitations under the License. */ %> matchingPadUrl = 'ep/pad/view/' + matchingPadId + '/latest'; } %> -
  • -

    <%= matchingPadId %>

    -

  • +
    <%= matchingPadId %>
    +
    + <% for (j = 0; j < matchingPads[i].TAGS.length; j++) { %> + <%= matchingPads[i].TAGS[j] %> + <% } %> +
    <% } %> -
+
-- cgit v1.2.3-1-g7c22 From b47214788eae996a9aa6ce7aff1755208ce1f289 Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:35:03 +0100 Subject: Changed the homepage to be the tag search page --- trunk/etherpad/src/main.js | 4 ++-- trunk/etherpad/src/templates/tag/tag_search.ejs | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/trunk/etherpad/src/main.js b/trunk/etherpad/src/main.js index 61e365f..0bba1f0 100644 --- a/trunk/etherpad/src/main.js +++ b/trunk/etherpad/src/main.js @@ -363,7 +363,7 @@ function handlePath() { [PrefixMatcher('/static/'), forward(static_control)], [PrefixMatcher('/ep/genimg/'), genimg.renderPath], [PrefixMatcher('/ep/pad/'), forward(pad_control)], - [PrefixMatcher('/ep/tag/'), forward(tag_control)], + [PrefixMatcher('/ep/tag'), forward(tag_control)], [PrefixMatcher('/ep/script/'), forward(scriptcontrol)], [/^\/([^\/]+)$/, pad_control.render_pad], [DirMatcher('/ep/unit-tests/'), forward(testcontrol)], @@ -373,7 +373,7 @@ function handlePath() { var etherpadDotComDispatcher = new Dispatcher(); etherpadDotComDispatcher.addLocations([ - ['/', maincontrol.render_main], + ['/', forward(tag_control)], // maincontrol.render_main], [DirMatcher('/ep/beta-account/'), forward(pro_beta_control)], [DirMatcher('/ep/pro-signup/'), forward(pro_signup_control)], [DirMatcher('/ep/about/'), forward(aboutcontrol)], diff --git a/trunk/etherpad/src/templates/tag/tag_search.ejs b/trunk/etherpad/src/templates/tag/tag_search.ejs index ccf38ff..9284665 100644 --- a/trunk/etherpad/src/templates/tag/tag_search.ejs +++ b/trunk/etherpad/src/templates/tag/tag_search.ejs @@ -58,7 +58,9 @@ limitations under the License. */ %>
-- cgit v1.2.3-1-g7c22 From 15d2e99f5ac0bc2491da1c719b6fa89f5cd75b36 Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:37:34 +0100 Subject: Added a MOTD functionality --- trunk/etherpad/etc/etherpad.localdev-default.properties | 1 + trunk/etherpad/src/etherpad/control/tag/tag_control.js | 1 + trunk/etherpad/src/templates/tag/tag_search.ejs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/trunk/etherpad/etc/etherpad.localdev-default.properties b/trunk/etherpad/etc/etherpad.localdev-default.properties index c2a2122..89c9c4f 100644 --- a/trunk/etherpad/etc/etherpad.localdev-default.properties +++ b/trunk/etherpad/etc/etherpad.localdev-default.properties @@ -14,3 +14,4 @@ modulePath = ./src transportPrefix = /comet transportUseWildcardSubdomains = true useVirtualFileRoot = ./src +motdPage = http://localhost:9000/ep/pad/view/ro.7zur9ouBTk9/latest?fullScreen=1&slider=0&sidebar=0 diff --git a/trunk/etherpad/src/etherpad/control/tag/tag_control.js b/trunk/etherpad/src/etherpad/control/tag/tag_control.js index bf9e9b2..32f01e3 100644 --- a/trunk/etherpad/src/etherpad/control/tag/tag_control.js +++ b/trunk/etherpad/src/etherpad/control/tag/tag_control.js @@ -225,6 +225,7 @@ function onRequest() { renderHtml("tag/tag_search.ejs", { + config: appjet.config, tagsToQuery: tagsToQuery, padIdToReadonly: server_utils.padIdToReadonly, tags: tags, diff --git a/trunk/etherpad/src/templates/tag/tag_search.ejs b/trunk/etherpad/src/templates/tag/tag_search.ejs index 9284665..ba00aa2 100644 --- a/trunk/etherpad/src/templates/tag/tag_search.ejs +++ b/trunk/etherpad/src/templates/tag/tag_search.ejs @@ -65,7 +65,7 @@ limitations under the License. */ %>
-
NANANANA
+
-- cgit v1.2.3-1-g7c22 From e15b217f5516c329466f67d2bdab348aedfe07ef Mon Sep 17 00:00:00 2001 From: Egil Moeller Date: Fri, 12 Mar 2010 21:38:21 +0100 Subject: select distinct in tag search --- trunk/etherpad/src/etherpad/control/tag/tag_control.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trunk/etherpad/src/etherpad/control/tag/tag_control.js b/trunk/etherpad/src/etherpad/control/tag/tag_control.js index 32f01e3..7d38270 100644 --- a/trunk/etherpad/src/etherpad/control/tag/tag_control.js +++ b/trunk/etherpad/src/etherpad/control/tag/tag_control.js @@ -102,7 +102,7 @@ function getQueryToSql(tags, antiTags, querySql) { return { sql: stringFormat( - '(select ' + + '(select distinct ' + ' p.ID ' + ' from ' + ' %(queryTable)s as p ' + -- cgit v1.2.3-1-g7c22