diff --git a/.ep_initialized b/.ep_initialized new file mode 100644 index 0000000..348ebd9 --- /dev/null +++ b/.ep_initialized @@ -0,0 +1 @@ +done \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..366aa1a --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2013 THE ETHERPAD FOUNDATION + + 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. diff --git a/README.md b/README.md new file mode 100755 index 0000000..e1d982b --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +## Who made what changes to a pad? + +And when? This plugin aids to help authors that have been absent from a pad only to return and find that the content they contributed has been removed. The author can see who removed content and also who added new content. + +To use the plugin visit the timeslider and click the Magnifying glass button. + +# Installing + +Option 1. + +Use the ``/admin`` interface, search for ``ep_who_did_what`` and click Install + +Option 2. +``` +npm install ep_who_did_what +``` + +Option 3. +``` +cd your_etherpad_install/node_modules +git clone https://github.com/JohnMcLear/ep_who_did_what +``` + +# Bug Reports + +Please submit bug reports or patches at https://github.com/JohnMcLear/ep_who_did_what/issues + +# Todo + +- [ ] Full test coverage diff --git a/ep.json b/ep.json new file mode 100755 index 0000000..b172f35 --- /dev/null +++ b/ep.json @@ -0,0 +1,20 @@ +{ + "parts": [ + { + "name": "ep_who_did_what", + "hooks": { + "eejsBlock_timesliderStyles": "ep_who_did_what/index", + "eejsBlock_timesliderScripts": "ep_who_did_what/index", + "eejsBlock_timesliderEditbarRight": "ep_who_did_what/index", + "eejsBlock_timesliderBody": "ep_who_did_what/index", + "clientVars": "ep_who_did_what/index", + "eejsBlock_mySettings": "ep_who_did_what/index", + "eejsBlock_body": "ep_who_did_what/index", + "eejsBlock_customStyles": "ep_who_did_what/index", + "expressCreateServer": "ep_who_did_what/express" + }, + "client_hooks": { + } + } + ] +} diff --git a/exportWhoDidWhat.js b/exportWhoDidWhat.js new file mode 100644 index 0000000..5ead415 --- /dev/null +++ b/exportWhoDidWhat.js @@ -0,0 +1,144 @@ +var async = require("ep_etherpad-lite/node_modules/async"); +var Changeset = require("ep_etherpad-lite/static/js/Changeset"); +var padManager = require("ep_etherpad-lite/node/db/PadManager"); +var ERR = require("ep_etherpad-lite/node_modules/async-stacktrace"); +var Security = require('ep_etherpad-lite/static/js/security'); +var authorManager = require('ep_etherpad-lite/node/db/AuthorManager') +var Pad = require('ep_etherpad-lite/node/db/Pad') + +exports.whoDidWhat = async function(padId, revNum, callback) +{ + let exists = await padManager.doesPadExists(padId); + if (!exists) { + console.error("Pad does not exist"); + process.exit(1); + } + + // get the pad + let pad = await padManager.getPad(padId); + var head = pad.getHeadRevisionNumber(); + + //create an array with all revisions + var revisions = []; + var beginningTime; + var endTime; + + for(var i=0;i<=head;i++) + { + revisions.push(i); + } + + let authors = await pad.getAllAuthors(); + var authorsObj = {}; + var items = {}; + var prevDate; + var threshold = 10; // CAKE TODO + + for(var author in authors){ + let authr = await authorManager.getAuthor(authors[author]); + let color = await authorManager.getAuthorColorId(authors[author]); + let authorName = await authorManager.getAuthorName(authors[author]); + authorsObj[authors[author]] = {}; + authorsObj[authors[author]].name = authorName; + + if(typeof color === "string" && color.indexOf("#") !== -1){ + authorsObj[authors[author]].color = color; + }else{ + // color needs to come from index + let palette = authorManager.getColorPalette(); + color = palette[color]; + authorsObj[authors[author]].color = color; + } + } + + //run trough all revisions + async.forEachSeries(revisions, async function(revNum, callback){ + + let revision = await pad.getRevision(revNum); + + if(authorsObj[revision.meta.author]){ + var authorColor = authorsObj[revision.meta.author].color.toUpperCase(); + var authorName = authorsObj[revision.meta.author].name; + if(!authorName) authorName = "Anonymous" + var opType = typeOfOp(revision.changeset); + var unpacked = Changeset.unpack(revision.changeset); + var changeLength = Math.abs(unpacked.oldLen - unpacked.newLen); + var per = Math.round(( 100 / unpacked.oldLen) * changeLength); + var humanTime = new Date(revision.meta.timestamp).toLocaleTimeString(); + var humanDate = new Date(revision.meta.timestamp).toDateString(); + + if(opType === "="){ + var actionString = "changed some attributes" + var keyword = "orange"; + } + if(opType === "-"){ + var actionString = "removed some content("+changeLength+" chars[" + per +"%]"; + var keyword = "red"; + } + if(opType === "+"){ + var actionString = "added some content("+changeLength+" chars[" + per +"%]"; + var keyword = "green" + } + + if(prevDate === humanDate){ + var logString = "#" + revNum + " at " + humanTime + " " + authorName + " " + actionString; + }else{ + if(per > threshold){ + // console.log(humanDate); + var logString = "#" + revNum + " at " + humanTime + " " + authorName + " " + actionString; + } + } + + // By default we ignore any percentage that is lower than 1% + if(per > threshold){ + // console.log(keyword, logString); + prevDate = humanDate; + items[revNum] = { + "timestamp": revision.meta.timestamp, + "time": humanTime, + "date": humanDate, + "authorName": authorName, + "opType": opType, + "changeLength": changeLength, + "percent": per + } + } + + } + + // setImmediate required else it will crash on large pads + // See https://caolan.github.io/async/v3/ Common Pitfalls + async.setImmediate(function() { + callback() + }); + }, function(){ + callback(null, items); + }); +}; + +// returns "-", "+" or "=" depending on the type of edit +function typeOfOp(changeset){ + var unpacked = Changeset.unpack(changeset); + var iter = Changeset.opIterator(unpacked.ops); + while (iter.hasNext()) { + var o = iter.next(); + var code; + switch (o.opcode) { + case '=': + code = "=" + break; + case '-': + code = "-" + break; + case '+': + { + code = "+" + break; + } + } + } + + return code; +} + + diff --git a/express.js b/express.js new file mode 100644 index 0000000..0a5cf70 --- /dev/null +++ b/express.js @@ -0,0 +1,14 @@ +var exportWhoDidWhat = require('./exportWhoDidWhat'); + +exports.expressCreateServer = function (hook_name, args, cb) { + args.app.get('/p/:pad/:rev?/export/whoDidWhat', function(req, res, next) { + var padID = req.params.pad; + var revision = req.params.rev ? req.params.rev : null; + + exportWhoDidWhat.whoDidWhat(padID, revision, function(err, result) { +// res.attachment(padID+'.json'); + res.contentType('text/json'); + res.send(result); + }); + }); +}; diff --git a/index.js b/index.js new file mode 100755 index 0000000..dd6cf10 --- /dev/null +++ b/index.js @@ -0,0 +1,67 @@ +/** + * Copyright 2020 John McLear + * + * 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. + */ +var log4js = require('ep_etherpad-lite/node_modules/log4js') +var statsLogger = log4js.getLogger("stats"); +var eejs = require('ep_etherpad-lite/node/eejs/'); +var settings = require('ep_etherpad-lite/node/utils/Settings'); +var stats = require('ep_etherpad-lite/node/stats') + +exports.eejsBlock_timesliderStyles = function(fn, args, cb){ + return cb(); +} + +exports.eejsBlock_timesliderScripts = function(fn, args, cb){ + args.content = args.content + eejs.require("ep_who_did_what/static/js/whoDidWhat.js"); +// ""; + return cb(); +} + +exports.eejsBlock_timesliderEditbarRight = function(fn, args, cb){ + args.content = args.content + eejs.require("ep_who_did_what/templates/button.ejs", {}, module); + return cb(); +} + +exports.eejsBlock_timesliderBody = function(fn, args, cb){ + args.content = args.content + eejs.require("ep_who_did_what/templates/modal.ejs", {}, module); +} + +exports.clientVars = function(hook, context, callback) +{ + if(!settings.ep_what_have_i_missed) settings.ep_what_have_i_missed = {}; + return callback({ + ep_what_have_i_missed: { + } + }); +}; + +exports.eejsBlock_mySettings = function (hook, context, callback) +{ + if(!settings.ep_what_have_i_missed) settings.ep_what_have_i_missed = {}; + context.content += eejs.require('ep_what_have_i_missed/templates/settings.ejs', { + }); + callback(); +}; + +exports.eejsBlock_customStyles = function (hook_name, args, cb) { + args.content = args.content + eejs.require("ep_what_have_i_missed/templates/styles.html", {}, module); + return cb(); +}; + + +exports.eejsBlock_body = function (hook_name, args, cb) { + args.content = args.content + eejs.require("ep_what_have_i_missed/templates/diff.ejs", {}, module); + return cb(); +}; diff --git a/locales/en.json b/locales/en.json new file mode 100755 index 0000000..083c610 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,3 @@ +{ + "pad.ep_who_did_what_catchup.title": "Who made changes to this pad and when?" +} diff --git a/package.json b/package.json new file mode 100755 index 0000000..31f30af --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "ep_who_did_what", + "version": "0.0.1", + "description": "Who made what changes to a pad? A historical report available in the timeslider", + "author": "John McLear ", + "contributors": [], + "dependencies": {} +} diff --git a/static/js/diff.min.js b/static/js/diff.min.js new file mode 100644 index 0000000..976ad93 --- /dev/null +++ b/static/js/diff.min.js @@ -0,0 +1,38 @@ +/*! + + diff v4.0.1 + +Software License Agreement (BSD License) + +Copyright (c) 2009-2015, Kevin Decker + +All rights reserved. + +Redistribution and use of this software in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other + materials provided with the distribution. + +* Neither the name of Kevin Decker nor the names of its + contributors may be used to endorse or promote products + derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +@license +*/ +!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((e=e||self).Diff={})}(this,function(e){"use strict";function t(){}function g(e,n,t,r,i){for(var o=0,s=n.length,l=0,a=0;oe.length?t:e}),u.value=e.join(d)}else u.value=e.join(t.slice(l,l+u.count));l+=u.count,u.added||(a+=u.count)}}var c=n[s-1];return 1=c&&h<=r+1)return d([{value:this.join(u),count:u.length}]);function i(){for(var e=-1*p;e<=p;e+=2){var n=void 0,t=v[e-1],r=v[e+1],i=(r?r.newPos:0)-e;t&&(v[e-1]=void 0);var o=t&&t.newPos+1=c&&h<=i+1)return d(g(f,n.components,u,a,f.useLongestToken));v[e]=n}else v[e]=void 0}var l;p++}if(n)!function e(){setTimeout(function(){if(t=v.length-2&&t.length<=p.context){var u=/\n$/.test(c),f=/\n$/.test(h),d=0==t.length&&x.length>a.oldLines;!u&&d&&x.splice(a.oldLines,0,"\\ No newline at end of file"),(u||d)&&f||x.push("\\ No newline at end of file")}m.push(a),y=w=0,x=[]}L+=t.length,S+=t.length}},o=0;oe.length)return!1;for(var t=0;t"):r.removed&&n.push(""),n.push((i=r.value,void 0,i.replace(/&/g,"&").replace(//g,">").replace(/"/g,"""))),r.added?n.push(""):r.removed&&n.push("")}var i;return n.join("")},e.canonicalize=v,Object.defineProperty(e,"__esModule",{value:!0})}); \ No newline at end of file diff --git a/static/js/whoDidWhat.js b/static/js/whoDidWhat.js new file mode 100755 index 0000000..41a7c82 --- /dev/null +++ b/static/js/whoDidWhat.js @@ -0,0 +1,19 @@ + diff --git a/static/tests/frontend/specs/enable_disable.js b/static/tests/frontend/specs/enable_disable.js new file mode 100755 index 0000000..dce6b88 --- /dev/null +++ b/static/tests/frontend/specs/enable_disable.js @@ -0,0 +1,36 @@ +describe('enable and disable author follow', function() { + beforeEach(function(cb) { + // Make sure webrtc is disabled, and reload with the firefox fake webrtc pref + // (Chrome needs a CLI parameter to have fake webrtc) + helper.newPad(cb); + this.timeout(60000); + }); + + it('disables author follow if the user uses the checkbox', function(done) { + var chrome$ = helper.padChrome$; + var $cb = chrome$("#options-enableFollow"); + expect($cb.prop("checked")).to.be(true); + expect(chrome$.window.clientVars.ep_what_have_i_missed.enableFollow).to.be(true); + $cb.click(); + + expect($cb.prop("checked")).to.be(false) + + helper.waitFor(function(){ + return chrome$.window.clientVars.ep_what_have_i_missed.enableFollow === false; + }, 1000).done(done); + }); + + it('enables author follow if the user uses the checkbox', function(done) { + var chrome$ = helper.padChrome$; + var $cb = chrome$("#options-enableFollow"); + expect($cb.prop("checked")).to.be(true) + expect(chrome$.window.clientVars.ep_what_have_i_missed.enableFollow).to.be(true); + $cb.click(); + $cb.click(); + expect($cb.prop("checked")).to.be(true) + + helper.waitFor(function(){ + return chrome$.window.clientVars.ep_what_have_i_missed.enableFollow === true; + }, 1000).done(done); + }); +}); diff --git a/templates/button.ejs b/templates/button.ejs new file mode 100644 index 0000000..5511c11 --- /dev/null +++ b/templates/button.ejs @@ -0,0 +1,7 @@ + diff --git a/templates/diff.ejs b/templates/diff.ejs new file mode 100755 index 0000000..44b559d --- /dev/null +++ b/templates/diff.ejs @@ -0,0 +1,8 @@ + diff --git a/templates/modal.ejs b/templates/modal.ejs new file mode 100644 index 0000000..e794060 --- /dev/null +++ b/templates/modal.ejs @@ -0,0 +1,5 @@ + diff --git a/templates/settings.ejs b/templates/settings.ejs new file mode 100755 index 0000000..e69de29 diff --git a/templates/styles.html b/templates/styles.html new file mode 100755 index 0000000..44ed433 --- /dev/null +++ b/templates/styles.html @@ -0,0 +1,8 @@ +