Compare commits
35 Commits
master
...
feature/au
Author | SHA1 | Date | |
---|---|---|---|
|
1f051bfabc | ||
|
5da9f4a410 | ||
|
ea5aa0e80f | ||
|
090c01bf8a | ||
|
d41ab31b3d | ||
|
8550859366 | ||
|
f42e70c768 | ||
|
8d71ad6c1c | ||
|
701ea7058c | ||
|
2001ed423b | ||
|
bf5fc622ba | ||
|
b2a34caa66 | ||
|
7bf46d9b53 | ||
|
33741b9d2d | ||
|
9d63498def | ||
|
2347b41647 | ||
|
7174c6a686 | ||
|
43d8ae702d | ||
|
9aa1e9544d | ||
|
131951c976 | ||
|
7a3251ddfc | ||
|
7886c20aef | ||
|
d472262fee | ||
|
80cbf2676f | ||
|
4f152d03ac | ||
|
10960ecf1e | ||
|
1e542e612a | ||
|
a551271743 | ||
|
88689c789a | ||
|
bbd9e1b523 | ||
|
b2bc582f92 | ||
|
5f71b735e5 | ||
|
bffcc3af6f | ||
|
1a4e760fe0 | ||
|
65caa7ad99 |
47
.drone.yml
47
.drone.yml
@ -1,47 +0,0 @@
|
||||
---
|
||||
kind: pipeline
|
||||
name: default
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: amd64
|
||||
|
||||
steps:
|
||||
- name: publish
|
||||
pull: default
|
||||
image: plugins/docker:18.09
|
||||
settings:
|
||||
registry: https://registry.nixaid.com
|
||||
repo: "registry.nixaid.com/${DRONE_REPO_NAMESPACE}/${DRONE_REPO_NAME}"
|
||||
tags:
|
||||
- latest
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
# storage_path: /drone/docker
|
||||
# storage_driver: aufs
|
||||
# ipv6: false
|
||||
# debug: true
|
||||
when:
|
||||
branch:
|
||||
- master
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
|
||||
- name: notify
|
||||
pull: default
|
||||
image: drillster/drone-email:latest
|
||||
settings:
|
||||
from: "Drone CI <noreply@nixaid.com>"
|
||||
host: mx.nixaid.com
|
||||
port: 587
|
||||
subject: "NIXAID Drone Pipeline {{#success build.status}}SUCCESS{{else}}FAILURE{{/success}} Notification"
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
status:
|
||||
- success
|
||||
- failure
|
@ -1,85 +0,0 @@
|
||||
# workspace:
|
||||
# base: /workspace
|
||||
# path: src/git.nixaid.com/arno/myapp/
|
||||
#
|
||||
# branches:
|
||||
# - master
|
||||
|
||||
pipeline:
|
||||
restore_cache:
|
||||
image: drillster/drone-volume-cache:latest
|
||||
restore: true
|
||||
mount:
|
||||
- /drone/docker
|
||||
# Set the ``DRONE_VOLUME=/tmp/drone-cache:/cache`` drone-server variable,
|
||||
# so you can benefit from the caching.
|
||||
# Otherwise you will have to make this repository trusted in Drone and use
|
||||
# the volumes as follows.
|
||||
# volumes:
|
||||
# - /tmp/drone-cache:/cache
|
||||
|
||||
# drone repo add arno/isso
|
||||
# drone secret add/update --name docker_username --value arno --event push --event tag --event deployment arno/isso
|
||||
# drone secret add/update --name docker_password --value "$(pass show vps/registry.nixaid.com | head -1)" --event push --event tag --event deployment arno/isso
|
||||
publish:
|
||||
image: plugins/docker:17.12
|
||||
# repo: andrey01/${DRONE_REPO_NAME}
|
||||
registry: registry.nixaid.com
|
||||
repo: registry.nixaid.com/arno/${DRONE_REPO_NAME}
|
||||
tags:
|
||||
- latest
|
||||
# - ${DRONE_COMMIT_SHA:0:7}
|
||||
# group: docker
|
||||
# dockerfile: Dockerfile
|
||||
secrets: [docker_username, docker_password]
|
||||
# Since we restore the docker image cache to /drone/docker
|
||||
storage_path: /drone/docker
|
||||
use_cache: true
|
||||
when:
|
||||
event: [push, tag]
|
||||
branch: master
|
||||
|
||||
rebuild_cache:
|
||||
image: drillster/drone-volume-cache:latest
|
||||
rebuild: true
|
||||
mount:
|
||||
- /drone/docker
|
||||
# Set the ``DRONE_VOLUME=/tmp/drone-cache:/cache`` drone-server variable,
|
||||
# so you can benefit from the caching.
|
||||
# Otherwise you will have to make this repository trusted in Drone and use
|
||||
# the volumes as follows.
|
||||
# volumes:
|
||||
# - /tmp/drone-cache:/cache
|
||||
|
||||
# # ca_cert comes from /srv/data/registry/certs/ca.crt
|
||||
# claircheck:
|
||||
# # image: jmccann/drone-clair:1
|
||||
# image: andrey01/drone-clair
|
||||
# url: http://clair:6060
|
||||
# secrets: [ docker_username, docker_password ]
|
||||
# # ignore errors for now. This will work only in drone 0.9 https://github.com/drone/drone-runtime/commit/3e8bd99f60f4032226523320cd2b2321f9525159
|
||||
# err_ignore: true
|
||||
# scan_image: registry.nixaid.com/arno/${DRONE_REPO_NAME}:latest
|
||||
# ca_cert: |
|
||||
# -----BEGIN CERTIFICATE-----
|
||||
# MIIBOjCB4KADAgECAgkAzhpbLWXa4H0wCgYIKoZIzj0EAwIwEDEOMAwGA1UEAwwF
|
||||
# bXktQ0EwHhcNMTgwNzA5MjIzMTAzWhcNMjgwNzA2MjIzMTAzWjAQMQ4wDAYDVQQD
|
||||
# DAVteS1DQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFIE8bTfQ76U5qG/Xgjw
|
||||
# BbQU0oRJLYlRxBIWF9MTNSJr2LoaoyrU8jrcWQGRrfKPoVuwUJWp2tp5SJy0AHH7
|
||||
# 4fijIzAhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgKkMAoGCCqGSM49
|
||||
# BAMCA0kAMEYCIQCYbTbxRD2yX4LzGjh84fKPWPQM9ps8RE2nfwZjqdRUGgIhAOHb
|
||||
# USigh6FzqEPk2jiaV3t1wNtChRWRfupTKG6CD345
|
||||
# -----END CERTIFICATE-----
|
||||
|
||||
notify:
|
||||
image: drillster/drone-email:latest
|
||||
from: Drone CI <noreply@nixaid.com>
|
||||
subject: NIXAID Drone Pipeline {{#success build.status}}SUCCESS{{else}}FAILURE{{/success}} Notification
|
||||
host: mx.nixaid.com
|
||||
port: 587
|
||||
# username: arno
|
||||
# secrets: [ email_username, email_password ]
|
||||
# recipients: [ andrey.arapov@nixaid.com ]
|
||||
when:
|
||||
status: [success, failure] # changed
|
||||
event: [push, tag]
|
98
.gitignore
vendored
98
.gitignore
vendored
@ -3,12 +3,11 @@
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
*~
|
||||
# *~
|
||||
|
||||
*.pyc
|
||||
.Python
|
||||
.sass-cache
|
||||
.vagrant
|
||||
|
||||
/bin
|
||||
/include
|
||||
@ -26,96 +25,5 @@
|
||||
/docs/_build
|
||||
/docs/_static/css/site.css
|
||||
|
||||
/pip-selfcheck.json
|
||||
|
||||
# github/gitignore
|
||||
Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.hypothesis/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# IPython Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# dotenv
|
||||
.env
|
||||
|
||||
# virtualenv
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
/build
|
||||
/dist
|
||||
|
23
.travis.yml
23
.travis.yml
@ -1,22 +1,15 @@
|
||||
language: python
|
||||
matrix:
|
||||
include:
|
||||
- python: 3.5
|
||||
env: TOX_ENV=py35
|
||||
- python: 3.6
|
||||
env: TOX_ENV=py36
|
||||
- python: 3.7
|
||||
dist: xenial
|
||||
env: TOX_ENV=py37
|
||||
- python: 3.8
|
||||
dist: xenial
|
||||
env: TOX_ENV=py38
|
||||
python: 2.7
|
||||
env:
|
||||
- TOX_ENV=py27
|
||||
- TOX_ENV=py33
|
||||
- TOX_ENV=py34
|
||||
- TOX_ENV=wheezy
|
||||
install:
|
||||
- pip install -U pip
|
||||
- pip install flake8 tox
|
||||
- pip install tox
|
||||
- sudo rm -rf /dev/shm && sudo ln -s /run/shm /dev/shm
|
||||
script:
|
||||
- tox -e $TOX_ENV
|
||||
- make flakes
|
||||
notifications:
|
||||
irc:
|
||||
channels:
|
||||
|
247
CHANGES.rst
247
CHANGES.rst
@ -1,255 +1,16 @@
|
||||
Changelog for Isso
|
||||
==================
|
||||
|
||||
0.12.3 (UNRELEASED)
|
||||
-------------------
|
||||
|
||||
- New "flags" option in the [markdown] section to customize Misaka's Markdown
|
||||
HTML rendering. By default, no flags are set.
|
||||
|
||||
[markup]
|
||||
flags = skip-html, escape, hard-wrap
|
||||
|
||||
Check docs/configuration/server.rst for more details.
|
||||
|
||||
0.12.2 (2019-01-21)
|
||||
-------------------
|
||||
|
||||
- Revert use of labels instead of placeholders, since it breaks
|
||||
mail notifications. #524
|
||||
|
||||
0.12.1 (2019-01-19)
|
||||
-------------------
|
||||
|
||||
- Revert fix for duplicate slashes, as it prevents isso from
|
||||
starting in some cases. #523
|
||||
|
||||
0.12.0 (2019-01-18)
|
||||
-------------------
|
||||
|
||||
- Fix compatibility with new XML API.
|
||||
- Don't enable admin interface with default password by default. #491
|
||||
- Add support and documentation for "generic" imports.
|
||||
- Remove potential duplicate slashes in URLs from
|
||||
email links. #420
|
||||
- Add data-isso-reply-notifications to attributes in configuration.
|
||||
- Use default IP in imports if none is found. Fixes imports of some comments.
|
||||
- embed: fix feed link creation on older browsers.
|
||||
- Properly handle to field in mail notifications when using uWSGI spooler
|
||||
- css: fix vertical alignment of notification checkbox
|
||||
|
||||
0.11.1 (2018-11-03)
|
||||
-------------------
|
||||
|
||||
- Include pre-built minified JavaScript and CSS.
|
||||
|
||||
0.11.0 (2018-11-03)
|
||||
-------------------
|
||||
|
||||
Bugs & features:
|
||||
|
||||
- Fix link in moderation mails if isso is setup on a sub-url (e.g. domain.tld/comments/)
|
||||
- Add reply notifications
|
||||
- Add admin interface
|
||||
- Add links highlighting in comments
|
||||
- Add apidoc
|
||||
- Add rc.d script for FreeBSD
|
||||
- Add the possibility to set CORS Origin through ISSO_CORS_ORIGIN environ variable
|
||||
- Add preview button
|
||||
- Add Atom feed at /feed?uri={thread-id}
|
||||
- Add optionnal gravatar support
|
||||
- Add nofollow noopener on links inside comments
|
||||
- Add Dockerfile
|
||||
- Upgraded to Misaka 2
|
||||
- Some tests/travis/documentation improvements and fixes + pep8
|
||||
|
||||
Translations:
|
||||
|
||||
- Fix Chinese translation & typo in CJK
|
||||
- Add Danish translation
|
||||
- Add Hungarian translation
|
||||
- Add Persian translation
|
||||
- Improvement on german translation
|
||||
|
||||
0.10.6 (2016-09-22)
|
||||
-------------------
|
||||
|
||||
- fix missing configuration field
|
||||
|
||||
|
||||
0.10.5 (2016-09-20)
|
||||
-------------------
|
||||
|
||||
- add support for different vote levels, #260
|
||||
|
||||
List of vote levels used to customize comment appearance based on score.
|
||||
Provide a comma-separated values (eg. `"0,5,10,25,100"`) or a JSON array (eg.
|
||||
`"[-5,5,15]"`).
|
||||
|
||||
For example, the value `"-5,5"` will cause each `isso-comment` to be given
|
||||
one of these 3 classes:
|
||||
|
||||
- `isso-vote-level-0` for scores lower than `-5`
|
||||
- `isso-vote-level-1` for scores between `-5` and `4`
|
||||
- `isso-vote-level-2` for scores of `5` and greater
|
||||
|
||||
These classes can then be used to customize the appearance of comments (eg.
|
||||
put a star on popular comments).
|
||||
|
||||
- add new post preview API endpoint, #254
|
||||
|
||||
- add an option for mandatory author, #257
|
||||
|
||||
- clients can now use `data-title` to get the HTML title for a new page, #252
|
||||
|
||||
- add finish translation and other minor bugfixes
|
||||
|
||||
|
||||
0.10.4 (2016-04-12)
|
||||
-------------------
|
||||
|
||||
- fix wrapper attribute when using data-isso-require-mail="true", #238
|
||||
- fix reponse for OPTIONS response on Python 3, #242
|
||||
|
||||
|
||||
0.10.3 (2016-02-24)
|
||||
-------------------
|
||||
|
||||
- follow redirects, #193
|
||||
|
||||
|
||||
0.10.2 (2016-02-21)
|
||||
-------------------
|
||||
|
||||
- fix getAttribute return value
|
||||
|
||||
|
||||
0.10.1 (2016-02-06)
|
||||
-------------------
|
||||
|
||||
- fix empty author, email and website values when writing a comment
|
||||
|
||||
|
||||
0.10 (2016-02-06)
|
||||
0.10 (unreleased)
|
||||
-----------------
|
||||
|
||||
- add new configuration section for hash handling.
|
||||
|
||||
[hash]
|
||||
salt = Eech7co8Ohloopo9Ol6baimi
|
||||
algorithm = pbkdf2
|
||||
|
||||
You can customize the salt, choose different hash functions and tweak the
|
||||
parameters for PBKDF2.
|
||||
|
||||
- Python 3.4+ validate TLS connections against the system's CA. Previously no
|
||||
validation was in place, see PEP-446__ for details.
|
||||
|
||||
- add `fenced_code` and `no_intra_emphasis` to default configuration.
|
||||
|
||||
Fenced code allows to write code without indentation using `~~~` delimiters
|
||||
(optionally with language identifier).
|
||||
|
||||
Intra emphasis would compile `foo_bar_baz` to foo<em>bar</em>baz. This
|
||||
behavior is very confusing for users not knowing the Markdown spec in detail.
|
||||
|
||||
- new configuration to require an email when submitting comments, #199. Set
|
||||
|
||||
[guard]
|
||||
require-email = true
|
||||
|
||||
and use `data-isso-require-email="true"` to enable this feature. Disabled by
|
||||
default.
|
||||
|
||||
- new Bulgarian translation by sahwar, new Swedish translation by Gustav
|
||||
Näslund – #143, new Vietnamese translation by Đinh Xuân Sâm, new Croatian
|
||||
translation by streger, new Czech translation by Jan Chren
|
||||
|
||||
- fix SMTP setup without credentials, #174
|
||||
|
||||
- version pin Misaka to 1.x, html5lib to 0.9999999
|
||||
|
||||
.. __: https://www.python.org/dev/peps/pep-0466/
|
||||
- Nothing changed yet.
|
||||
|
||||
|
||||
0.9.10 (2015-04-11)
|
||||
-------------------
|
||||
|
||||
- fix regression in SMTP authentication, #174
|
||||
|
||||
|
||||
0.9.9 (2015-03-04)
|
||||
0.9.5 (unreleased)
|
||||
------------------
|
||||
|
||||
- several Python 3.x related bugfixes
|
||||
|
||||
- don't lose comment form if the server rejected the POST request, #144
|
||||
|
||||
- add localStorage fallback if QUOTA_EXCEEDED_ERR is thrown (e.g. Safari
|
||||
private browsing)
|
||||
|
||||
- add '--empty-id' flag to Disqus import, because Disqus' export sucks
|
||||
|
||||
- (re)gain compatibility with Werkzeug 0.8 and really old html5lib versions
|
||||
available in Debian Squeeze, #170 & #168
|
||||
|
||||
- add User-Agent when Isso requests the URL, an alternate way to #151 (add
|
||||
'X-Isso' when requesting).
|
||||
|
||||
0.9.8 (2014-10-08)
|
||||
------------------
|
||||
|
||||
- add compatibility with configparser==3.5.0b1, #128
|
||||
|
||||
|
||||
0.9.7 (2014-09-25)
|
||||
------------------
|
||||
|
||||
- fix SMTP authentication using CRAM-MD5 (incorrect usage of
|
||||
`smtplib`), #126
|
||||
|
||||
|
||||
0.9.6 (2014-08-18)
|
||||
------------------
|
||||
|
||||
- remember name, email and website in localStorage, #119
|
||||
|
||||
- add option to hide voting feature, #115
|
||||
|
||||
data-isso-vote="true|false"
|
||||
|
||||
- remove email field from JSON responses
|
||||
|
||||
This is a quite serious issue. For the identicon, an expensive hash is used
|
||||
to avoid the leakage of personal information like a real email address. A
|
||||
`git blame` reveals, the email has been unintenionally exposed since the very
|
||||
first release of Isso :-/
|
||||
|
||||
The testsuite now contains a dedicated test to prevent this error in the
|
||||
future.
|
||||
|
||||
|
||||
0.9.5 (2014-08-10)
|
||||
------------------
|
||||
|
||||
- prevent no-break space ( ) insertion to enable manual line breaks using
|
||||
two trailing spaces (as per Markdown convention), #112
|
||||
|
||||
- limit request size to 256 kb, #107
|
||||
|
||||
Previously unlimited or limited by proxy server). 256 kb is a rough
|
||||
approximation of the next database schema with comments limited to 65535
|
||||
characters and additional fields.
|
||||
|
||||
- add support for logging to file, #103
|
||||
|
||||
[general]
|
||||
log-file =
|
||||
|
||||
- show timestamp when hovering <time>, #104
|
||||
|
||||
- fix a regression when editing comments with multiple paragraphs introduced
|
||||
in 0.9.3 which would HTML escape manually inserted linebreaks.
|
||||
- Nothing changed yet.
|
||||
|
||||
|
||||
0.9.4 (2014-07-09)
|
||||
|
@ -4,11 +4,6 @@
|
||||
|
||||
* Martin Zimmermann <info@posativ.org>
|
||||
|
||||
## Co-Maintainers
|
||||
|
||||
* Benoît Latinier @blatinier <benoit@latinier.fr>
|
||||
|
||||
* Jelmer Vernooij <jelmer@jelmer.uk>
|
||||
|
||||
## Contributors
|
||||
|
||||
@ -46,76 +41,6 @@ In chronological order:
|
||||
* Baptiste Darthenay
|
||||
* Esperanto translation
|
||||
|
||||
* Matías Ducasa <https://github.com/matiasducasa>
|
||||
* Spanish translation
|
||||
|
||||
* Daniel Gräber <https://github.com/albohlabs>
|
||||
* Added ansible for provisioning
|
||||
|
||||
* Nick Hu <https://github.com/NickHu>
|
||||
* Added configuration to require email addresses (no validation)
|
||||
* Fix Vagrantfile
|
||||
|
||||
* Benoît Latinier @blatinier <benoit@latinier.fr>
|
||||
* Fix thread discovery
|
||||
* Added mandatory author
|
||||
* Added admin interface
|
||||
|
||||
* Ivan Pantic <ivanpantic82@gmail.com>
|
||||
* Added vote levels
|
||||
|
||||
* Martin Schenck @schemar
|
||||
* Improvement in the german translation
|
||||
|
||||
* @cclauss
|
||||
* Pep8 and drop of legacy supports (old python & debian version tested in travis)
|
||||
* Make travis use pyflakes
|
||||
|
||||
* Lucas Cimon @Lucas-C
|
||||
* Added the possibility to define CORS origins through ISSO_CORS_ORIGIN environment variable
|
||||
* Fix a bug with <a> in <svg>
|
||||
* Fixing likes counter of replies not being displayed
|
||||
* Adding contrib/dump_comments.py
|
||||
* Adding a [server] proxy-fix-enable-x-prefix configuration option
|
||||
* Using .access_route instead of .remote_addr to take into account HTTP_X_FORWARDED_FOR header
|
||||
|
||||
* Yuchen Pei @ycpei
|
||||
* Fix link in moderation emails when isso is installed in a sub URL
|
||||
|
||||
* @Rocket1184
|
||||
* Fix typo in CJK translations
|
||||
|
||||
* @vincentbernat
|
||||
* Added documentation about data-isso-id attribute (overriding the standard isso-thread-id)
|
||||
* Added multi-staged Dockerfile
|
||||
* Added atom feed
|
||||
* Added a nofollow/noopener on links inside comments to protect against bots
|
||||
* Added a preview using the existing preview endpoint
|
||||
|
||||
* @p-vitt & @M4a1x
|
||||
* Documentation on troubleshooting for uberspace users
|
||||
|
||||
* Facundo Batista <facundo@taniquetil.com.ar>
|
||||
* Added a generic way to migrate from a json file
|
||||
|
||||
* @benjhess
|
||||
* Optionnal gravatar support
|
||||
|
||||
* Steffen Prince @sprin
|
||||
* Upgrade to Misaka 2
|
||||
|
||||
* Rocka <i@rocka.me>
|
||||
* Implementation and documentation about async comment loading
|
||||
|
||||
* Pelle Nilsson @pellenilsson
|
||||
* Reply notifications
|
||||
|
||||
* Craig P Hicks @craigphicks
|
||||
* Fix sub urls configurations on admin interface
|
||||
|
||||
* Chris Warrick @Kwpolska
|
||||
* Update Polish translation
|
||||
* Redirect to comment after moderation
|
||||
|
||||
* [Your name or handle] <[email or website]>
|
||||
* [Brief summary of your changes]
|
||||
|
||||
|
34
Dockerfile
34
Dockerfile
@ -1,34 +0,0 @@
|
||||
# First, compile JS stuff
|
||||
FROM node:dubnium-buster
|
||||
WORKDIR /src/
|
||||
COPY . .
|
||||
RUN npm install -g requirejs uglify-js jade bower \
|
||||
&& make init js
|
||||
|
||||
# Second, create virtualenv
|
||||
FROM python:3.8-buster
|
||||
WORKDIR /src/
|
||||
COPY --from=0 /src .
|
||||
RUN python3 -m venv /isso \
|
||||
&& . /isso/bin/activate \
|
||||
&& pip3 install --no-cache-dir --upgrade pip \
|
||||
&& pip3 install --no-cache-dir gunicorn cffi flask \
|
||||
&& python setup.py install \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Third, create final repository
|
||||
FROM python:3.8-slim-buster
|
||||
WORKDIR /isso/
|
||||
COPY --from=1 /isso .
|
||||
|
||||
# Configuration
|
||||
VOLUME /db /config
|
||||
EXPOSE 8080
|
||||
ENV ISSO_SETTINGS /config/isso.cfg
|
||||
CMD ["/isso/bin/gunicorn", "-b", "0.0.0.0:8080", "-w", "4", "--preload", "isso.run", "--worker-tmp-dir", "/dev/shm"]
|
||||
|
||||
# Example of use:
|
||||
#
|
||||
# docker build -t isso .
|
||||
# docker run -it --rm -v /opt/isso:/config -v /opt/isso:/db -v $PWD:$PWD isso /isso/bin/isso -c \$ISSO_SETTINGS import disqus.xml
|
||||
# docker run -d --rm --name isso -p 8080:8080 -v /opt/isso:/config -v /opt/isso:/db isso
|
11
MANIFEST.in
11
MANIFEST.in
@ -1,19 +1,12 @@
|
||||
include man/man1/isso.1
|
||||
include man/man5/isso.conf.5
|
||||
|
||||
include isso/defaults.ini
|
||||
include share/isso.conf
|
||||
|
||||
include isso/js/embed.min.js
|
||||
include isso/js/embed.dev.js
|
||||
include isso/js/count.min.js
|
||||
include isso/js/count.dev.js
|
||||
include isso/js/admin.js
|
||||
|
||||
include isso/defaults.ini
|
||||
|
||||
include isso/templates/admin.html
|
||||
include isso/templates/disabled.html
|
||||
include isso/templates/login.html
|
||||
include isso/css/admin.css
|
||||
include isso/css/isso.css
|
||||
include isso/img/isso.svg
|
||||
include isso/demo/index.html
|
||||
|
22
Makefile
22
Makefile
@ -1,5 +1,3 @@
|
||||
# INSTALLATION: pip install sphinx && npm install --global node-sass
|
||||
|
||||
ISSO_JS_SRC := $(shell find isso/js/app -type f) \
|
||||
$(shell ls isso/js/*.js | grep -vE "(min|dev)") \
|
||||
isso/js/lib/requirejs-jade/jade.js
|
||||
@ -9,7 +7,7 @@ ISSO_JS_DST := isso/js/embed.min.js isso/js/embed.dev.js \
|
||||
|
||||
ISSO_CSS := isso/css/isso.css
|
||||
|
||||
ISSO_PY_SRC := $(shell git ls-files | grep -E "^isso/.+.py$$")
|
||||
ISSO_PY_SRC := $(shell git ls-files | grep .py)
|
||||
|
||||
DOCS_RST_SRC := $(shell find docs/ -type f -name '*.rst') \
|
||||
$(wildcard docs/_isso/*) \
|
||||
@ -27,34 +25,26 @@ DOCS_MAN_DST := man/man1/isso.1 man/man5/isso.conf.5
|
||||
|
||||
DOCS_HTML_DST := docs/_build/html
|
||||
|
||||
RJS = r.js
|
||||
|
||||
SASS = node-sass
|
||||
|
||||
all: man js site
|
||||
|
||||
init:
|
||||
(cd isso/js; bower --allow-root install almond requirejs requirejs-text jade)
|
||||
|
||||
flakes:
|
||||
flake8 . --count --max-line-length=127 --show-source --statistics
|
||||
(cd isso/js; bower install almond requirejs requirejs-text jade)
|
||||
|
||||
isso/js/%.min.js: $(ISSO_JS_SRC) $(ISSO_CSS)
|
||||
$(RJS) -o isso/js/build.$*.js out=$@
|
||||
r.js -o isso/js/build.$*.js out=$@
|
||||
|
||||
isso/js/%.dev.js: $(ISSO_JS_SRC) $(ISSO_CSS)
|
||||
$(RJS) -o isso/js/build.$*.js optimize="none" out=$@
|
||||
r.js -o isso/js/build.$*.js optimize="none" out=$@
|
||||
|
||||
js: $(ISSO_JS_DST)
|
||||
|
||||
man: $(DOCS_RST_SRC)
|
||||
sphinx-build -b man docs/ man/
|
||||
mkdir -p man/man1/ man/man5
|
||||
mv man/isso.1 man/man1/isso.1
|
||||
mv man/isso.conf.5 man/man5/isso.conf.5
|
||||
|
||||
${DOCS_CSS_DST}: $(DOCS_CSS_SRC) $(DOCS_CSS_DEP)
|
||||
$(SASS) --no-cache $(DOCS_CSS_SRC) $@
|
||||
scss --no-cache $(DOCS_CSS_SRC) $@
|
||||
|
||||
${DOCS_HTML_DST}: $(DOCS_RST_SRC) $(DOCS_CSS_DST)
|
||||
sphinx-build -b dirhtml docs/ $@
|
||||
@ -65,7 +55,7 @@ coverage: $(ISSO_PY_SRC)
|
||||
nosetests --with-doctest --with-coverage --cover-package=isso --cover-html isso/
|
||||
|
||||
test: $($ISSO_PY_SRC)
|
||||
python3 setup.py nosetests
|
||||
python setup.py nosetests
|
||||
|
||||
clean:
|
||||
rm -f $(DOCS_MAN_DST) $(DOCS_CSS_DST) $(ISSO_JS_DST)
|
||||
|
@ -1,5 +1,3 @@
|
||||
[![Build Status](https://drone.nixaid.com/api/badges/arno/isso/status.svg)](https://drone.nixaid.com/arno/isso)
|
||||
|
||||
Isso – a commenting server similar to Disqus
|
||||
============================================
|
||||
|
||||
|
65
Vagrantfile
vendored
65
Vagrantfile
vendored
@ -1,65 +0,0 @@
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
|
||||
# This is the Vagrant config file for setting up an environment for Isso development.
|
||||
# It requires:
|
||||
#
|
||||
# * Vagrant (https://vagrantup.com)
|
||||
# * A VM engine, like VirtualBox (https://virtualbox.org)
|
||||
# * The Vagrant-Hostmanager plugin (https://github.com/smdahlen/vagrant-hostmanager)
|
||||
# * Ansible (https://www.ansible.com)
|
||||
#
|
||||
# With them installed, cd into this directory and do 'vagrant up'. It's possible Vagrant will
|
||||
# ask for your root password so it can update your /etc/hosts file. Once it's happily churning out
|
||||
# console output, go get a coffee :)
|
||||
#
|
||||
# The resulting VM should be accessible at http://isso-dev.local/ so you can try the demo page out.
|
||||
# Edit files in your checkout as usual. If you need to look at log files and stuff, 'vagrant ssh'
|
||||
# to get into the VM. Useful info about it:
|
||||
#
|
||||
# * Running Ubuntu 14.04
|
||||
# * Isso is running on uWSGI
|
||||
# * Actual webserver is Nginx to talk to uWSGI over a unix socket
|
||||
# * uWSGI log file is /var/log/uwsgi/apps/isso.log
|
||||
# * Isso DB file is /var/isso/comments.db
|
||||
# * Isso log file is /var/log/isso.log
|
||||
#
|
||||
# When the VM is getting rebooted vagrant mounts the shared folder after uWSGI is getting startet. To fix this issue for
|
||||
# the moment you need to 'vagrant ssh' into the VM and execute 'sudo service uwsgi restart'.
|
||||
#
|
||||
# For debugging with _pudb_ stop uWSGI service and start it manually
|
||||
# 'sudo uwsgi --ini /etc/uwsgi/apps-available/isso.ini'.
|
||||
#
|
||||
# Enjoy!
|
||||
|
||||
Vagrant.configure(2) do |config|
|
||||
|
||||
# The most common configuration options are documented and commented below.
|
||||
# For a complete reference, please see the online documentation at
|
||||
# https://docs.vagrantup.com.
|
||||
|
||||
config.vm.box = "ubuntu/trusty32"
|
||||
|
||||
config.vm.hostname = 'isso-dev.local'
|
||||
config.vm.network "private_network", type: "dhcp"
|
||||
|
||||
config.hostmanager.enabled = true
|
||||
config.hostmanager.manage_host = true
|
||||
config.hostmanager.ignore_private_ip = false
|
||||
config.hostmanager.include_offline = true
|
||||
config.hostmanager.ip_resolver = proc do |machine|
|
||||
result = ""
|
||||
machine.communicate.execute("ifconfig eth1") do |type, data|
|
||||
result << data if type == :stdout
|
||||
end
|
||||
(ip = /inet addr:(\d+\.\d+\.\d+\.\d)/.match(result)) && ip[1]
|
||||
end
|
||||
|
||||
config.vm.provision "ansible" do |ansible|
|
||||
ansible.playbook = "ansible/site.yml"
|
||||
ansible.limit = "all"
|
||||
ansible.verbose = "v"
|
||||
end
|
||||
|
||||
config.vm.post_up_message = "Browse to http://isso-dev.local/demo/index.html to start."
|
||||
end
|
@ -1,39 +0,0 @@
|
||||
user root;
|
||||
worker_processes 4;
|
||||
worker_rlimit_nofile 8192;
|
||||
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 2014;
|
||||
multi_accept on;
|
||||
use epoll;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
log_format timed '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for" '
|
||||
'$request_time $upstream_response_time $upstream_addr '
|
||||
' $upstream_status $upstream_cache_status $pipe';
|
||||
|
||||
access_log /var/log/nginx/access.log timed;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
|
||||
keepalive_timeout 30;
|
||||
|
||||
gzip on;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
include /etc/nginx/sites-enabled/*;
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
server {
|
||||
client_max_body_size 20M;
|
||||
listen 80 default_server;
|
||||
server_name isso-dev.local;
|
||||
|
||||
root /vagrant/isso/demo;
|
||||
|
||||
location / {
|
||||
# uwsgi_pass unix:///run/uwsgi/app/isso/socket;
|
||||
uwsgi_pass 127.0.0.1:8080;
|
||||
include uwsgi_params;
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
[uwsgi]
|
||||
plugins = python
|
||||
|
||||
chdir = /vagrant
|
||||
|
||||
uid = root
|
||||
gid = root
|
||||
|
||||
socket = :8080
|
||||
|
||||
master = true
|
||||
processes = 4
|
||||
cache2 = name=hash,items=10240,blocksize=32
|
||||
spooler = /var/isso/spool
|
||||
module = isso.run
|
||||
env = ISSO_SETTINGS=/vagrant/share/isso-dev.conf
|
||||
env = PYTHON_EGG_CACHE=/tmp
|
||||
|
||||
# uncomment for debugging
|
||||
# daemonize = /var/log/uwsgi/uwsgi.log
|
||||
py-autoreload = 1
|
||||
|
||||
# prevent uWSGI from remapping stdin to /dev/null
|
||||
honour-stdin = true
|
@ -1,85 +0,0 @@
|
||||
---
|
||||
|
||||
- name: Provision development server
|
||||
hosts: all
|
||||
sudo: true
|
||||
tasks:
|
||||
|
||||
- name: Apt | Add nodesource keys
|
||||
apt_key: url=https://deb.nodesource.com/gpgkey/nodesource.gpg.key state=present
|
||||
|
||||
- name: Apt | Add nodesource sources list deb
|
||||
apt_repository: repo='deb https://deb.nodesource.com/node {{ ansible_distribution_release }} main' state=present
|
||||
|
||||
- name: Apt | Add nodesource sources list deb src
|
||||
apt_repository: repo='deb-src https://deb.nodesource.com/node {{ ansible_distribution_release }} main' state=present
|
||||
|
||||
- name: Apt | Install packages
|
||||
apt: pkg={{ item }} state=latest update_cache=true
|
||||
with_items:
|
||||
- build-essential
|
||||
- curl
|
||||
- htop
|
||||
- vim
|
||||
- git
|
||||
- python-dev
|
||||
- python-software-properties
|
||||
- python-setuptools
|
||||
- python-pip
|
||||
- nginx
|
||||
- uwsgi
|
||||
- uwsgi-plugin-python
|
||||
- supervisor
|
||||
- sqlite3
|
||||
- nodejs
|
||||
- libffi-dev
|
||||
|
||||
- name: NPM | Install packages
|
||||
npm: name={{ item }} global=yes
|
||||
with_items:
|
||||
- bower
|
||||
- requirejs
|
||||
- uglify-js
|
||||
- jade
|
||||
|
||||
- name: Python | Install egg
|
||||
shell: cd /vagrant; python setup.py develop
|
||||
|
||||
- name: Make
|
||||
shell: cd /vagrant; {{ item }}
|
||||
with_items:
|
||||
- make init
|
||||
- make js
|
||||
|
||||
- name: Spool | Create directory
|
||||
file: path=/var/isso/spool state=directory mode=0777
|
||||
|
||||
- name: uwsgi | Deploy configuration
|
||||
copy: src=files/uwsgi.ini dest=/etc/uwsgi/apps-available/isso.ini
|
||||
|
||||
- name: uwsgi | Enable app
|
||||
file: src=/etc/uwsgi/apps-available/isso.ini dest=/etc/uwsgi/apps-enabled/isso.ini state=link
|
||||
|
||||
- name: uwsgi | Restart service daemon
|
||||
service: name=uwsgi state=restarted enabled=yes
|
||||
|
||||
- name: uwsgi | Chmod logfile
|
||||
file: path=/var/log/uwsgi/uwsgi.log state=touch mode="a+r"
|
||||
|
||||
- name: nginx | Deploy nginx.conf
|
||||
copy: src=files/nginx.conf dest=/etc/nginx/nginx.conf
|
||||
|
||||
- name: nginx | Delete default vhost
|
||||
action: file path=/etc/nginx/sites-enabled/default state=absent
|
||||
|
||||
- name: nginx | Deploy vhost config
|
||||
copy: src=files/nginx.vhost.conf dest=/etc/nginx/sites-available/isso.conf
|
||||
|
||||
- name: nginx | Enable vhost
|
||||
file: src=/etc/nginx/sites-available/isso.conf dest=/etc/nginx/sites-enabled/000-isso state=link
|
||||
|
||||
- name: nginx | Chmod logfile
|
||||
file: path=/var/log/nginx mode="a+rx" state=directory recurse=true
|
||||
|
||||
- name: nginx | Restart service daemon
|
||||
service: name=nginx state=restarted enabled=yes
|
11
apidoc.json
11
apidoc.json
@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "isso",
|
||||
"description": "a Disqus alternative",
|
||||
"title": "isso API",
|
||||
"order": ["Thread", "Comment"],
|
||||
"template": {
|
||||
"withCompare": false
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# The MIT License (MIT)
|
||||
#
|
||||
# Copyright (c) 2020 Lucas Cimon.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
|
||||
"""Dump isso comments as text
|
||||
|
||||
The script can be run like this:
|
||||
|
||||
contrib/dump_comments.py .../path/to/isso.db --sort-by-last-reply
|
||||
|
||||
To get a list of all available options:
|
||||
|
||||
contrib/dump_comments.py --help
|
||||
|
||||
By installing the optional colorama dependency, you'll get a colored output.
|
||||
An example of output can be found at https://github.com/posativ/isso/issues/634
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
from collections import defaultdict, namedtuple
|
||||
from datetime import date
|
||||
from textwrap import indent
|
||||
|
||||
|
||||
class ColorFallback():
|
||||
__getattr__ = lambda self, name: '' # noqa: E731
|
||||
|
||||
|
||||
try:
|
||||
from colorama import Fore, Style, init
|
||||
init() # needed for Windows
|
||||
except ImportError: # fallback so that the imported classes always exist
|
||||
Fore = Style = ColorFallback()
|
||||
|
||||
|
||||
Comment = namedtuple('Comment', ('uri', 'id', 'parent', 'created', 'text', 'author', 'email', 'website', 'likes', 'dislikes', 'replies'))
|
||||
|
||||
INDENT = ' '
|
||||
QUERY = 'SELECT uri, comments.id, parent, created, text, author, email, website, likes, dislikes FROM comments INNER JOIN threads on comments.tid = threads.id'
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
if not args.colors:
|
||||
global Fore, Style
|
||||
Fore = Style = ColorFallback()
|
||||
db = sqlite3.connect(args.db_path)
|
||||
comments_per_uri = defaultdict(list)
|
||||
for result in db.execute(QUERY).fetchall():
|
||||
comment = Comment(*result, replies=[])
|
||||
comments_per_uri[comment.uri].append(comment)
|
||||
root_comments_per_sort_date = {}
|
||||
for comments in comments_per_uri.values():
|
||||
comments_per_id = {comment.id: comment for comment in comments}
|
||||
root_comments, sort_date = [], None
|
||||
for comment in comments:
|
||||
if comment.parent: # == this is a "reply" comment
|
||||
comments_per_id[comment.parent].replies.append(comment)
|
||||
if args.sort_by_last_reply and (sort_date is None or comment.created > sort_date):
|
||||
sort_date = comment.created
|
||||
else:
|
||||
root_comments.append(comment)
|
||||
if sort_date is None or comment.created > sort_date:
|
||||
sort_date = comment.created
|
||||
root_comments_per_sort_date[sort_date] = root_comments
|
||||
for _, root_comments in sorted(root_comments_per_sort_date.items(), key=lambda pair: pair[0]):
|
||||
print(Fore.MAGENTA + args.url_prefix + root_comments[0].uri + Fore.RESET)
|
||||
for comment in root_comments:
|
||||
print_comment(INDENT, comment)
|
||||
for comment in comment.replies:
|
||||
print_comment(INDENT * 2, comment)
|
||||
print()
|
||||
|
||||
|
||||
def print_comment(prefix, comment):
|
||||
author = comment.author or 'Anonymous'
|
||||
email = comment.email or ''
|
||||
website = comment.website or ''
|
||||
when = date.fromtimestamp(comment.created)
|
||||
popularity = ''
|
||||
if comment.likes:
|
||||
popularity = '+{.likes}'.format(comment)
|
||||
if comment.dislikes:
|
||||
if popularity:
|
||||
popularity += '/'
|
||||
popularity = '-{.dislikes}'.format(comment)
|
||||
print(prefix + '{Style.BRIGHT}{author}{Style.RESET_ALL} {Style.DIM}- {email} {website}{Style.RESET_ALL} {when} {Style.DIM}{popularity}{Style.RESET_ALL}'.format(Style=Style, **locals()))
|
||||
print(indent(comment.text, prefix))
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description='Dump all Isso comments in chronological order, grouped by replies',
|
||||
formatter_class=ArgparseHelpFormatter)
|
||||
parser.add_argument('db_path', help='File path to Isso Sqlite DB')
|
||||
parser.add_argument('--sort-by-last-reply', action='store_true', help='By default comments are sorted by "parent" comment date, this sort comments based on the last replies')
|
||||
parser.add_argument('--url-prefix', default='', help='Optional domain name to prefix to pages URLs')
|
||||
parser.add_argument('--no-colors', action='store_false', dest='colors', default=True, help='Disabled colored output')
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
class ArgparseHelpFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
"""Comment importer from Blogger
|
||||
|
||||
This python script can convert comments posted to a Blogger-powered blog to a
|
||||
JSON file with can then be imported into Isso (by following the procedure
|
||||
explained in docs/docs/extras/advanced-migration.rst.
|
||||
|
||||
The script can be run like this:
|
||||
|
||||
python import_blogger.py -p 'http://myblog.com/' blogger.xml out.json
|
||||
|
||||
where `blogger.xml` is a dump of the blog produced by the Blogger platform, and
|
||||
the URL following the `-p` option is a prefix that will be applied to all post
|
||||
URLs: the original host will be stripped and the path will be appended to the
|
||||
string you specify here (this can be useful in the case that your blog moved to
|
||||
a different domain, subdomain, or just into a new directory).
|
||||
The `out.json` file is the file which will be generated by this tool, and which
|
||||
can then be fed into isso:
|
||||
|
||||
isso -c /path/to/isso.cfg import -t generic out.json
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
|
||||
import feedparser
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class Post:
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
self.title = None
|
||||
self.comments = []
|
||||
|
||||
def add_comment(self, comment):
|
||||
comment['id'] = len(self.comments) + 1
|
||||
self.comments.append(comment)
|
||||
|
||||
|
||||
def encode_post(post):
|
||||
ret = {}
|
||||
ret['id'] = post.url
|
||||
ret['title'] = post.title
|
||||
ret['comments'] = post.comments
|
||||
return ret
|
||||
|
||||
|
||||
class ImportBlogger:
|
||||
TYPE_COMMENT = 'http://schemas.google.com/blogger/2008/kind#comment'
|
||||
TYPE_POST = 'http://schemas.google.com/blogger/2008/kind#post'
|
||||
|
||||
def __init__(self, filename_in, filename_out, prefix):
|
||||
self.channel = feedparser.parse(filename_in)
|
||||
self.filename_out = filename_out
|
||||
self.prefix = prefix
|
||||
|
||||
def run(self):
|
||||
self.posts = {}
|
||||
for item in self.channel.entries:
|
||||
terms = [tag.term for tag in item.tags]
|
||||
if not terms:
|
||||
continue
|
||||
if terms[0] == self.TYPE_COMMENT:
|
||||
post = self.ensure_post(item)
|
||||
post.add_comment(self.process_comment(item))
|
||||
elif terms[0] == self.TYPE_POST:
|
||||
self.process_post(item)
|
||||
|
||||
data = [encode_post(p) for p in self.posts.values() if p.comments]
|
||||
with open(self.filename_out, 'w') as fp:
|
||||
json.dump(data, fp, indent=2)
|
||||
|
||||
def process_post(self, item):
|
||||
pid = self.post_id(item)
|
||||
if pid in self.posts:
|
||||
post = self.posts[pid]
|
||||
else:
|
||||
post = Post(pid)
|
||||
self.posts[pid] = post
|
||||
post.title = item.title
|
||||
|
||||
def ensure_post(self, item):
|
||||
pid = self.post_id(item)
|
||||
post = self.posts.get(pid, None)
|
||||
if not post:
|
||||
post = Post(pid)
|
||||
self.posts[pid] = post
|
||||
return post
|
||||
|
||||
def process_comment(self, item):
|
||||
comment = {}
|
||||
comment['author'] = item.author_detail.name
|
||||
comment['email'] = item.author_detail.email
|
||||
comment['website'] = item.author_detail.get('href', '')
|
||||
t = time.strftime('%Y-%m-%d %H:%M:%S', item.published_parsed)
|
||||
comment['created'] = t
|
||||
comment['text'] = item.content[0].value
|
||||
comment['remote_addr'] = '127.0.0.1'
|
||||
return comment
|
||||
|
||||
def post_id(self, item):
|
||||
u = urlparse(item.link)
|
||||
return self.prefix + u.path
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Convert comments from blogger.com')
|
||||
parser.add_argument('input', help='input file')
|
||||
parser.add_argument('output', help='output file')
|
||||
parser.add_argument('-p', dest='prefix',
|
||||
help='prefix to be added to paths (ID)',
|
||||
type=str, default='')
|
||||
args = parser.parse_args()
|
||||
|
||||
importer = ImportBlogger(args.input, args.output, args.prefix)
|
||||
importer.run()
|
@ -10,8 +10,7 @@
|
||||
VERSION: '{{ release|e }}',
|
||||
COLLAPSE_INDEX: false,
|
||||
FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}',
|
||||
HAS_SOURCE: {{ has_source|lower }},
|
||||
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
|
||||
HAS_SOURCE: {{ has_source|lower }}
|
||||
};
|
||||
</script>
|
||||
{%- for scriptfile in script_files %}
|
||||
|
@ -9,3 +9,4 @@ class IssoTranslator(HTMLTranslator):
|
||||
if self.section_level == 1:
|
||||
raise nodes.SkipNode
|
||||
HTMLTranslator.visit_title(self, node)
|
||||
|
||||
|
@ -28,5 +28,4 @@
|
||||
{{ doc("docs/extras/deployment", "Deployment") }}
|
||||
{{ doc("docs/extras/advanced-integration", "Advanced Integration") }}
|
||||
{{ doc("docs/extras/api", "API") }}
|
||||
{{ doc("docs/extras/contribs", "Community tools") }}
|
||||
</ul>
|
||||
|
28
docs/conf.py
28
docs/conf.py
@ -26,7 +26,7 @@ except pkg_resources.DistributionNotFound:
|
||||
dist = type("I'm a Version", (object, ), {})
|
||||
with io.open(join(dirname(__file__), "../setup.py")) as fp:
|
||||
for line in fp:
|
||||
m = re.match("\\s*version='([^']+)'\\s*", line)
|
||||
m = re.match("\s*version='([^']+)'\s*", line)
|
||||
if m:
|
||||
dist.version = m.group(1)
|
||||
break
|
||||
@ -64,7 +64,7 @@ master_doc = 'docs/index'
|
||||
|
||||
# General information about the project.
|
||||
project = u'Isso'
|
||||
copyright = u'2016, Martin Zimmermann'
|
||||
copyright = u'2014, Martin Zimmermann'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
@ -171,7 +171,7 @@ html_static_path = ['_static']
|
||||
|
||||
# Additional templates that should be rendered to pages, maps page names to
|
||||
# template names.
|
||||
html_additional_pages = {"index": "index.html"}
|
||||
html_additional_pages = {"index": "docs/index.html"}
|
||||
|
||||
# If false, no module index is generated.
|
||||
html_domain_indices = False
|
||||
@ -200,22 +200,22 @@ htmlhelp_basename = 'Issodoc'
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#'papersize': 'letterpaper',
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#'pointsize': '10pt',
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#'preamble': '',
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#'preamble': '',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
('index', 'Isso.tex', u'Isso Documentation',
|
||||
u'Martin Zimmermann', 'manual'),
|
||||
('index', 'Isso.tex', u'Isso Documentation',
|
||||
u'Martin Zimmermann', 'manual'),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
@ -260,9 +260,9 @@ man_pages = [
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
('index', 'Isso', u'Isso Documentation',
|
||||
u'Martin Zimmermann', 'Isso', 'a commenting server similar to Disqus',
|
||||
'Miscellaneous'),
|
||||
('index', 'Isso', u'Isso Documentation',
|
||||
u'Martin Zimmermann', 'Isso', 'a commenting server similar to Disqus',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
|
@ -59,3 +59,5 @@ definitely need help:
|
||||
- delete or activate comments matching a filter (e.g. name, email, ip address)
|
||||
|
||||
- close threads and remove threads completely
|
||||
|
||||
- edit comments
|
||||
|
@ -7,39 +7,23 @@ preferably in the script tag which embeds the JS:
|
||||
.. code-block:: html
|
||||
|
||||
<script data-isso="/prefix/"
|
||||
data-isso-id="thread-id"
|
||||
data-isso-css="true"
|
||||
data-isso-lang="ru"
|
||||
data-isso-reply-to-self="false"
|
||||
data-isso-require-author="false"
|
||||
data-isso-require-email="false"
|
||||
data-isso-reply-notifications="false"
|
||||
data-isso-max-comments-top="10"
|
||||
data-isso-max-comments-nested="5"
|
||||
data-isso-reveal-on-click="5"
|
||||
data-isso-avatar="true"
|
||||
data-isso-avatar-bg="#f0f0f0"
|
||||
data-isso-avatar-fg="#9abf88 #5698c4 #e279a3 #9163b6 ..."
|
||||
data-isso-vote="true"
|
||||
data-isso-vote-levels=""
|
||||
data-isso-feed="false"
|
||||
data-avatar-bg="#f0f0f0"
|
||||
data-avatar-fg="#9abf88 #5698c4 #e279a3 #9163b6 ..."
|
||||
src="/prefix/js/embed.js"></script>
|
||||
|
||||
Furthermore you can override the automatic title detection inside
|
||||
the embed tag, as well as the thread ID, e.g.:
|
||||
the embed tag, e.g.:
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
<section id="isso-thread" data-title="Foo!" data-isso-id="/path/to/resource"></section>
|
||||
|
||||
Additionally, you can override any translation string for any language by adding
|
||||
a ``data-isso-`` attribute that is equal to the translation key (found `here`__) with
|
||||
``-text-[lang]`` appended to it. So, for example, if you want to override the
|
||||
english translation of the ``postbox-notification`` message, you could add:
|
||||
|
||||
``data-isso-postbox-notification-text-en="Select to be notified of replies to your comment"``
|
||||
|
||||
.. __: https://github.com/posativ/isso/blob/master/isso/js/app/i18n/en.js
|
||||
<section id="isso-thread" data-title="Foo!"></section>
|
||||
|
||||
data-isso
|
||||
---------
|
||||
@ -65,33 +49,14 @@ Defaults to `true`.
|
||||
data-isso-lang
|
||||
--------------
|
||||
|
||||
Override useragent's preferred language. Isso has been translated in over 12
|
||||
languages. The language is configured by its `ISO 639-1
|
||||
<https://en.wikipedia.org/wiki/ISO_639-1>`_ (two letter) code.
|
||||
|
||||
You find a list of all supported languages on `GitHub
|
||||
<https://github.com/posativ/isso/tree/master/isso/js/app/i18n>`_.
|
||||
Override useragent's preferred language. Currently available: german (de),
|
||||
english (en), french (fr), italian (it), esperanto (eo) and russian (ru).
|
||||
|
||||
data-isso-reply-to-self
|
||||
-----------------------
|
||||
|
||||
Set to `true` when spam guard is configured with `reply-to-self = true`.
|
||||
|
||||
data-isso-require-author
|
||||
------------------------
|
||||
|
||||
Set to `true` when spam guard is configured with `require-author = true`.
|
||||
|
||||
data-isso-require-email
|
||||
-----------------------
|
||||
|
||||
Set to `true` when spam guard is configured with `require-email = true`.
|
||||
|
||||
data-isso-reply-notifications
|
||||
-----------------------------
|
||||
|
||||
Set to `true` when reply notifications is configured with `reply-notifications = true`.
|
||||
|
||||
data-isso-max-comments-top and data-isso-max-comments-nested
|
||||
------------------------------------------------------------
|
||||
|
||||
@ -123,36 +88,14 @@ scheme is based in `this color palette <http://colrd.com/palette/19308/>`_.
|
||||
Multiple colors must be separated by space. If you use less than eight colors
|
||||
and not a multiple of 2, the color distribution is not even.
|
||||
|
||||
data-isso-gravatar
|
||||
------------------
|
||||
data-isso-id
|
||||
------------
|
||||
|
||||
Uses gravatar images instead of generating svg images. You have to set
|
||||
"data-isso-avatar" to **false** when you want to use this. Otherwise
|
||||
both the gravatar and avatar svg image will show up. Please also set
|
||||
option "gravatar" to **true** in the server configuration...
|
||||
Broken – do not use. https://github.com/posativ/isso/issues/27
|
||||
|
||||
data-isso-vote
|
||||
--------------
|
||||
Set a custom thread id, defaults to current URI. If you a comment counter, add
|
||||
this attribute to the link tag, too.
|
||||
|
||||
Enable or disable voting feature on the client side.
|
||||
.. code-block:: html
|
||||
|
||||
data-isso-vote-levels
|
||||
---------------------
|
||||
|
||||
List of vote levels used to customize comment appearance based on score.
|
||||
Provide a comma-separated values (eg. `"0,5,10,25,100"`) or a JSON array (eg. `"[-5,5,15]"`).
|
||||
|
||||
For example, the value `"-5,5"` will cause each `isso-comment` to be given one of these 3 classes:
|
||||
|
||||
- `isso-vote-level-0` for scores lower than `-5`
|
||||
- `isso-vote-level-1` for scores between `-5` and `4`
|
||||
- `isso-vote-level-2` for scores of `5` and greater
|
||||
|
||||
These classes can then be used to customize the appearance of comments (eg. put a star on popular comments)
|
||||
|
||||
data-isso-feed
|
||||
--------------
|
||||
|
||||
Enable or disable the addition of a link to the feed for the comment
|
||||
thread. The link will only be valid if the appropriate setting, in
|
||||
``[rss]`` section, is also enabled server-side.
|
||||
<section data-isso-id="test.abc" id="isso-thread"></section>
|
||||
|
@ -44,7 +44,6 @@ session key and hostname. Here are the default values for this section:
|
||||
host =
|
||||
max-age = 15m
|
||||
notify = stdout
|
||||
log-file =
|
||||
|
||||
dbpath
|
||||
file location to the SQLite3 database, highly recommended to change this
|
||||
@ -55,7 +54,7 @@ name
|
||||
not used otherwise.
|
||||
|
||||
host
|
||||
Your website(s). If Isso is unable to connect to at least one site, you'll
|
||||
Your website(s). If Isso is unable to connect to at least on site, you'll
|
||||
get a warning during startup and comments are most likely non-functional.
|
||||
|
||||
You'll need at least one host/website to run Isso. This is due to security
|
||||
@ -81,40 +80,12 @@ notify
|
||||
Available backends:
|
||||
|
||||
stdout
|
||||
Log to standard output. Default, if none selected. Note, this
|
||||
functionality is broken since a few releases.
|
||||
Log to standard output. Default, if none selected.
|
||||
|
||||
smtp
|
||||
Send notifications via SMTP on new comments with activation (if
|
||||
moderated) and deletion links.
|
||||
|
||||
reply-notifications
|
||||
Allow users to request E-mail notifications for replies to their post.
|
||||
|
||||
It is highly recommended to also turn on moderation when enabling this
|
||||
setting, as Isso can otherwise be easily exploited for sending spam.
|
||||
|
||||
Do not forget to configure the client accordingly.
|
||||
|
||||
log-file
|
||||
Log console messages to file instead of standard out.
|
||||
|
||||
gravatar
|
||||
When set to ``true`` this will add the property "gravatar_image"
|
||||
containing the link to a gravatar image to every comment. If a comment
|
||||
does not contain an email address, gravatar will render a random icon.
|
||||
This is only true when using the default value for "gravatar-url"
|
||||
which contains the query string param ``d=identicon`` ...
|
||||
|
||||
gravatar-url
|
||||
Url for gravatar images. The "{}" is where the email hash will be placed.
|
||||
Defaults to "https://www.gravatar.com/avatar/{}?d=identicon"
|
||||
|
||||
latest-enabled
|
||||
If True it will enable the ``/latest`` endpoint. Optional, defaults
|
||||
to False.
|
||||
|
||||
|
||||
|
||||
.. _CORS: https://developer.mozilla.org/en/docs/HTTP/Access_control_CORS
|
||||
|
||||
@ -128,21 +99,13 @@ Enable moderation queue and handling of comments still in moderation queue
|
||||
|
||||
[moderation]
|
||||
enabled = false
|
||||
approve-if-email-previously-approved = false
|
||||
purge-after = 30d
|
||||
|
||||
enabled
|
||||
enable comment moderation queue. This option only affects new comments.
|
||||
Comments in moderation queue are not visible to other users until you
|
||||
Comments in modertion queue are not visible to other users until you
|
||||
activate them.
|
||||
|
||||
approve-if-email-previously-approved
|
||||
automatically approve comments by an email address if that address has
|
||||
had a comment approved within the last 6 months. No ownership verification
|
||||
is done on the entered email address. This means that if someone is able
|
||||
to guess correctly the email address used by a previously approved author,
|
||||
they will be able to have their new comment auto-approved.
|
||||
|
||||
purge-after
|
||||
remove unprocessed comments in moderation queue after given time.
|
||||
|
||||
@ -176,12 +139,6 @@ listen
|
||||
|
||||
Does not apply for `uWSGI`.
|
||||
|
||||
public-endpoint
|
||||
public URL that Isso is accessed from by end users. Should always be
|
||||
a http:// or https:// absolute address. If left blank, automatic
|
||||
detection is attempted. Normally only needs to be specified if
|
||||
different than the `listen` setting.
|
||||
|
||||
reload
|
||||
reload application, when the source code has changed. Useful for
|
||||
development. Only works with the internal webserver.
|
||||
@ -190,13 +147,6 @@ profile
|
||||
show 10 most time consuming function in Isso after each request. Do
|
||||
not use in production.
|
||||
|
||||
trusted-proxies
|
||||
an optional list of reverse proxies IPs behind which you have deployed
|
||||
your Isso web service (e.g. `127.0.0.1`).
|
||||
This allow for proper remote address resolution based on a
|
||||
`X-Forwarded-For` HTTP header, which is important for the mechanism
|
||||
forbiding several comment votes coming from the same subnet.
|
||||
|
||||
.. _configure-smtp:
|
||||
|
||||
SMTP
|
||||
@ -262,15 +212,13 @@ for IPv4, ``/48`` for IPv6).
|
||||
ratelimit = 2
|
||||
direct-reply = 3
|
||||
reply-to-self = false
|
||||
require-author = false
|
||||
require-email = false
|
||||
|
||||
enabled
|
||||
enable guard, recommended in production. Not useful for debugging
|
||||
purposes.
|
||||
|
||||
ratelimit
|
||||
limit to N new comments per minute.
|
||||
limit to N new comments per minute. Use -1 to disable rate limit.
|
||||
|
||||
direct-reply
|
||||
how many comments directly to the thread (prevent a simple
|
||||
@ -281,19 +229,7 @@ reply-to-self
|
||||
the comment. After the editing timeframe is gone, commenters can reply to
|
||||
their own comments anyways.
|
||||
|
||||
Do not forget to configure the `client <client>`_ accordingly
|
||||
|
||||
require-author
|
||||
force commenters to enter a value into the author field. No validation is
|
||||
performed on the provided value.
|
||||
|
||||
Do not forget to configure the `client <client>`_ accordingly.
|
||||
|
||||
require-email
|
||||
force commenters to enter a value into the email field. No validation is
|
||||
performed on the provided value.
|
||||
|
||||
Do not forget to configure the `client <client>`_ accordingly.
|
||||
Do not forget to configure the client.
|
||||
|
||||
Markup
|
||||
------
|
||||
@ -305,20 +241,12 @@ supported, but new languages are relatively easy to add.
|
||||
|
||||
[markup]
|
||||
options = strikethrough, superscript, autolink
|
||||
flags = skip-html, escape, hard-wrap
|
||||
allowed-elements =
|
||||
allowed-attributes =
|
||||
|
||||
options
|
||||
`Misaka-specific Markdown extensions <https://misaka.61924.nl/#api>`_, all
|
||||
extension flags can be used there, separated by comma, either by their name
|
||||
or as `EXT_`_.
|
||||
|
||||
flags
|
||||
`Misaka-specific HTML rendering flags
|
||||
<https://misaka.61924.nl/#html-render-flags>`_, all html rendering flags
|
||||
can be used here, separated by comma, either by their name or as `HTML_`_.
|
||||
Per Misaka's defaults, no flags are set.
|
||||
`Misaka-specific Markdown extensions <http://misaka.61924.nl/api/>`_, all
|
||||
flags starting with `EXT_` can be used there, separated by comma.
|
||||
|
||||
allowed-elements
|
||||
Additional HTML tags to allow in the generated output, comma-separated. By
|
||||
@ -361,45 +289,6 @@ algorithm
|
||||
Arguments have to be in that order, but can be reduced to `pbkdf2:4096`
|
||||
for example to override the iterations only.
|
||||
|
||||
.. _configure-rss:
|
||||
|
||||
RSS
|
||||
---
|
||||
|
||||
Isso can provide an Atom feed for each comment thread. Users can use
|
||||
them to subscribe to comments and be notified of changes. Atom feeds
|
||||
are enabled as soon as there is a base URL defined in this section.
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[rss]
|
||||
base =
|
||||
limit = 100
|
||||
|
||||
base
|
||||
base URL to use to build complete URI to pages (by appending the URI from Isso)
|
||||
|
||||
limit
|
||||
number of most recent comments to return for a thread
|
||||
|
||||
Admin
|
||||
-----
|
||||
|
||||
Isso has an optional web administration interface that can be used to moderate
|
||||
comments. The interface is available under ``/admin`` on your isso URL.
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[admin]
|
||||
enabled = true
|
||||
password = secret
|
||||
|
||||
enabled
|
||||
whether to enable the admin interface
|
||||
|
||||
password
|
||||
the plain text password to use for logging into the administration interface
|
||||
|
||||
Appendum
|
||||
--------
|
||||
|
||||
@ -412,18 +301,3 @@ Timedelta
|
||||
|
||||
You can add different types: `1m30s` equals to 90 seconds, `3h45m12s`
|
||||
equals to 3 hours, 45 minutes and 12 seconds (12512 seconds).
|
||||
|
||||
Environment variables
|
||||
---------------------
|
||||
|
||||
.. _environment-variables:
|
||||
|
||||
Isso also support configuration through some environment variables:
|
||||
|
||||
ISSO_CORS_ORIGIN
|
||||
By default, `isso` will use the `Host` or else the `Referrer` HTTP header
|
||||
of the request to defines a CORS `Access-Control-Allow-Origin` HTTP header
|
||||
in the response.
|
||||
This environent variable allows you to define a broader fixed value,
|
||||
in order for example to share a single Isso instance among serveral of your
|
||||
subdomains : `ISSO_CORS_ORIGIN=*.example.test`
|
||||
|
@ -21,36 +21,5 @@ Now, either include `count.min.js` if you want to show only the comment count
|
||||
(e.g. on an index page) or `embed.min.js` for the full comment client (see
|
||||
:doc:`../quickstart`); do not mix both.
|
||||
|
||||
You can have as many comments counters as you want in a page, and they will be
|
||||
merged into a single `GET` request.
|
||||
|
||||
Asynchronous comments loading
|
||||
-----------------------------
|
||||
|
||||
Isso will automatically fetch comments after `DOMContentLoaded` event. However
|
||||
in the case where your website is creating content dynamically (eg. via ajax),
|
||||
you need to re-fetch comment thread manually. Here is how you can re-fetch the
|
||||
comment thread:
|
||||
|
||||
.. code-block:: js
|
||||
|
||||
window.Isso.fetchComments()
|
||||
|
||||
It will delete all comments under the thread but not the PostBox, fetch
|
||||
comments with `data-isso-id` attribute of the element `section#isso-thread` (if
|
||||
that attribute does not exist, fallback to `window.location.pathname`), then
|
||||
fill comments into the thread. In other words, you should change `data-isso-id`
|
||||
attribute of the element `section#isso-thread` (or modify the pathname with
|
||||
`location.pushState`) before you can get new comments. And the thread element
|
||||
itself should *NOT* be touched or removed.
|
||||
|
||||
If you removed the `section#isso-thread` element, just create another element
|
||||
with same TagName and ID in which you wish comments to be placed, then call the
|
||||
`init` method of `Isso`:
|
||||
|
||||
.. code-block:: js
|
||||
|
||||
window.Isso.init()
|
||||
|
||||
Then Isso will initialize the comment section and fetch comments, as if the page
|
||||
was loaded.
|
||||
You can have as many comments counters as you want in a page but be aware that it
|
||||
implies one `GET` request per comment anchor.
|
||||
|
@ -1,70 +0,0 @@
|
||||
Advanced Migration
|
||||
==================
|
||||
|
||||
In quickstart we saw you can import comments from Disqus or WordPress. But there
|
||||
are a many other comments system and you could be using one of them.
|
||||
|
||||
Isso provides a way to import such comments, however it's up to you to to:
|
||||
|
||||
- dump comments
|
||||
- fit the data to the following JSON format::
|
||||
|
||||
A list of threads, each item being a dict with the following data:
|
||||
|
||||
- id: a text representing the unique thread id (note: by default isso
|
||||
associates this ID to the article URL, but it can be changed on
|
||||
client side with "data-isso-id" - see :doc:`client configuration <../configuration/client>` )
|
||||
- title: the title of the thread
|
||||
- comments: the list of comments
|
||||
|
||||
Each item in that list of comments is a dict with the following data:
|
||||
|
||||
- id: an integer with the unique id of the comment inside the thread
|
||||
(it can be repeated among different threads); this will be used to
|
||||
order the comment inside the thread
|
||||
- author: the author's name
|
||||
- email: the author's email
|
||||
- website: the author's website
|
||||
- remote_addr: the author's IP
|
||||
- created: a timestamp, in the format "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
[
|
||||
{
|
||||
"id": "/blog/article1",
|
||||
"title": "First article!",
|
||||
"comments": [
|
||||
{
|
||||
"author": "James",
|
||||
"created": "2018-11-28 17:24:23",
|
||||
"email": "email@mail.com",
|
||||
"id": "1",
|
||||
"remote_addr": "127.0.0.1",
|
||||
"text": "Great article!",
|
||||
"website": "http://fefzfzef.frzr"
|
||||
},
|
||||
{
|
||||
"author": "Harold",
|
||||
"created": "2018-11-28 17:58:03",
|
||||
"email": "email2@mail.com",
|
||||
"id": "2",
|
||||
"remote_addr": "",
|
||||
"text": "I hated it...",
|
||||
"website": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Keep in mind that isso expects to have an array, so keep the opening and ending square bracket even if you have only one article thread!
|
||||
|
||||
Next you can import you json dump:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
~> isso -c /path/to/isso.cfg import -t generic comment-dump.json
|
||||
[100%] 53 threads, 192 comments
|
||||
|
@ -23,11 +23,12 @@ Isso:
|
||||
"mode": 1,
|
||||
"hash": "4505c1eeda98",
|
||||
"author": null,
|
||||
"website": null,
|
||||
"email": null,
|
||||
"website": null
|
||||
"created": 1387321261.572392,
|
||||
"modified": null,
|
||||
"likes": 3,
|
||||
"dislikes": 0
|
||||
"dislikes": 0,
|
||||
}
|
||||
|
||||
id :
|
||||
@ -70,7 +71,7 @@ modified :
|
||||
List comments
|
||||
-------------
|
||||
|
||||
List all publicly visible comments for thread `uri`:
|
||||
List all publicely visible comments for thread `uri`:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
@ -83,19 +84,6 @@ plain :
|
||||
pass plain=1 to get the raw comment text, defaults to 0.
|
||||
|
||||
|
||||
Get the latest N comments for all threads:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
GET /latest?limit=N
|
||||
|
||||
The N parameter limits how many of the latest comments to retrieve; it's
|
||||
mandatory, and must be an integer greater than 0.
|
||||
|
||||
This endpoint needs to be enabled in the configuration (see the
|
||||
``latest-enabled`` option in the ``general`` section).
|
||||
|
||||
|
||||
Create comment
|
||||
--------------
|
||||
|
||||
@ -198,16 +186,3 @@ uri :
|
||||
|
||||
returns an integer
|
||||
|
||||
Get Atom feed
|
||||
-------------
|
||||
|
||||
Get an Atom feed of comments for thread `uri`:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
GET /feed?uri=%2Fhello-world%2F
|
||||
|
||||
uri :
|
||||
URI to get comments for, required.
|
||||
|
||||
Returns an XML document as the Atom feed.
|
||||
|
@ -1,20 +0,0 @@
|
||||
Community tools
|
||||
===============
|
||||
|
||||
Utility scripts
|
||||
---------------
|
||||
|
||||
Some utility scripts have been developed by isso users.
|
||||
They are stored in the `GitHub contrib/ directory
|
||||
<https://github.com/posativ/isso/tree/master/contrib>`_ :
|
||||
|
||||
* `dump_comments.py` : dump isso comments as text, optionally with color
|
||||
* `import_blogger.py` : comment importer from Blogger
|
||||
|
||||
|
||||
Related projects
|
||||
----------------
|
||||
|
||||
* `wonderfall/isso Docker image <https://github.com/Wonderfall/docker-isso>`
|
||||
* `a grav plugin to integrate isso comments <https://github.com/Sommerregen/grav-plugin-jscomments>`
|
||||
* `a Pelican theme supporting isso comments <https://github.com/Lucas-C/pelican-mg>`
|
@ -4,7 +4,7 @@ Deployment
|
||||
Isso ships with a built-in web server, which is useful for the initial setup
|
||||
and may be used in production for low-traffic sites (up to 20 requests per
|
||||
second). Running a "real" WSGI server supports nice things such as UNIX domain
|
||||
sockets, daemonization and solid HTTP handler. WSGI servers are more stable, secure
|
||||
sockets, daemonization and solid HTTP handler while being more stable, secure
|
||||
and web-scale than the built-in web server.
|
||||
|
||||
* gevent_, coroutine-based network library
|
||||
@ -13,11 +13,10 @@ and web-scale than the built-in web server.
|
||||
* mod_wsgi_, Apache interface to WSGI
|
||||
* mod_fastcgi_, Apache interface to FastCGI
|
||||
* uberspace.de, `try this guide (in german) <http://blog.posativ.org/2014/isso-und-uberspace-de/>`_
|
||||
* Openshift, Isso has a one click installer
|
||||
|
||||
|
||||
`gevent <http://www.gevent.org/>`__
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
`gevent <http://gunicorn.org/>`__
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Probably the easiest deployment method. Install with PIP (requires libevent):
|
||||
|
||||
@ -98,85 +97,11 @@ To execute Isso, use a command similar to:
|
||||
`mod_wsgi <https://code.google.com/p/modwsgi/>`__
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
First, create a startup script, called `isso.wsgi`. If Isso is in your system module
|
||||
search path, then the script is quite simple. This script is included in the
|
||||
isso distribution as `run.py`:
|
||||
.. note:: This information may be incorrect, if you have more knowledge on how
|
||||
to deploy Python via `mod_wsgi`, consider extending/correcting this section.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
from isso import make_app
|
||||
from isso import dist, config
|
||||
|
||||
application = make_app(
|
||||
config.load(
|
||||
os.path.join(dist.location, dist.project_name, "defaults.ini"),
|
||||
"/path/to/isso.cfg"),
|
||||
multiprocessing=True)
|
||||
|
||||
If you have installed Isso in a virtual environment, then you will have to add the path
|
||||
of the virtualenv to the site-specific paths of Python:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import site
|
||||
site.addsitedir("/path/to/isso_virtualenv")
|
||||
|
||||
import os
|
||||
|
||||
from isso import make_app
|
||||
from isso import dist, config
|
||||
|
||||
application = make_app(
|
||||
config.load(
|
||||
os.path.join(dist.location, dist.project_name, "defaults.ini"),
|
||||
"/path/to/isso.cfg",
|
||||
multiprocessing=True)
|
||||
|
||||
Using the aforementioned script will load system modules when available and modules
|
||||
from the virtualenv otherwise. Should you want the opposite behavior, where modules from
|
||||
the virtualenv have priority over system modules, the following script does the trick:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import site
|
||||
import sys
|
||||
|
||||
# Remember original sys.path.
|
||||
prev_sys_path = list(sys.path)
|
||||
|
||||
# Add the new site-packages directory.
|
||||
site.addsitedir("/path/to/isso_virtualenv")
|
||||
|
||||
# Reorder sys.path so new directories at the front.
|
||||
new_sys_path = []
|
||||
for item in list(sys.path):
|
||||
if item not in prev_sys_path:
|
||||
new_sys_path.append(item)
|
||||
sys.path.remove(item)
|
||||
sys.path[:0] = new_sys_path
|
||||
|
||||
from isso import make_app
|
||||
from isso import dist, config
|
||||
|
||||
application = make_app(
|
||||
config.load(
|
||||
os.path.join(dist.location, dist.project_name, "defaults.ini"),
|
||||
"/path/to/isso.cfg",
|
||||
multiprocessing=True)
|
||||
|
||||
The last two scripts are based on those given by
|
||||
`mod_wsgi documentation <https://code.google.com/p/modwsgi/wiki/VirtualEnvironments>`_.
|
||||
|
||||
The Apache configuration will then be similar to the following:
|
||||
For more information, see `Flask: Configuring Apache
|
||||
<http://flask.pocoo.org/docs/deploying/mod_wsgi/#configuring-apache>`_.
|
||||
|
||||
.. code-block:: apache
|
||||
|
||||
@ -184,22 +109,22 @@ The Apache configuration will then be similar to the following:
|
||||
ServerName example.org
|
||||
|
||||
WSGIDaemonProcess isso user=www-data group=www-data threads=5
|
||||
WSGIScriptAlias /mounted_isso_path /path/to/isso.wsgi
|
||||
WSGIScriptAlias / /var/www/isso.wsgi
|
||||
</VirtualHost>
|
||||
|
||||
You will need to adjust the user and group according to your Apache installation and
|
||||
security policy. Be aware that the directory containing the comments database must
|
||||
be writable by the user or group running the WSGI daemon process: having a writable
|
||||
database only is not enough, since SQLite will need to create a lock file in the same
|
||||
directory.
|
||||
Next, copy'n'paste to `/var/www/isso.wsgi`:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from isso import make_app
|
||||
from isso.core import Config
|
||||
|
||||
application = make_app(Config.load("/path/to/isso.cfg"))
|
||||
|
||||
|
||||
`mod_fastcgi <http://www.fastcgi.com/mod_fastcgi/docs/mod_fastcgi.html>`__
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
You can use this method if your hosting provider doesn't allow you to have long
|
||||
running processes. If FastCGI has not yet been configured in your server,
|
||||
please follow these steps:
|
||||
|
||||
.. note:: This information may be incorrect, if you have more knowledge on how
|
||||
to deploy Python via `mod_fastcgi`, consider extending/correcting this section.
|
||||
|
||||
@ -223,51 +148,19 @@ please follow these steps:
|
||||
</Location>
|
||||
</VirtualHost>
|
||||
|
||||
Next, to run isso as a FastCGI script you'll need to install ``flup`` with
|
||||
PIP:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
$ pip install flup
|
||||
|
||||
Finally, copy'n'paste to `/var/www/isso.fcgi` (or whatever location you prefer):
|
||||
Next, copy'n'paste to `/var/www/isso.fcgi` (or whatever location you prefer):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
#!/usr/bin/env python
|
||||
#: uncomment if you're using a virtualenv
|
||||
# import sys
|
||||
# sys.path.insert(0, '<your_local_path>/lib/python2.7/site-packages')
|
||||
# sys.insert(0, '<your_local_path>/lib/python2.7/site-packages')
|
||||
|
||||
from isso import make_app, dist, config
|
||||
import os
|
||||
from isso import make_app
|
||||
from isso.core import Config
|
||||
|
||||
from flup.server.fcgi import WSGIServer
|
||||
|
||||
application = make_app(config.load(
|
||||
os.path.join(dist.location, dist.project_name, "defaults.ini"),
|
||||
"/path/to/isso.cfg"))
|
||||
application = make_app(Config.load("/path/to/isso.cfg"))
|
||||
WSGIServer(application).run()
|
||||
|
||||
`Openshift <http://openshift.com>`__
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
With `Isso Openshift Deployment Kit`_, Isso can be installed on Open
|
||||
Shift with just one click. Make sure you already have installed ``rhc``
|
||||
(`instructions`_) and completed the setup.
|
||||
|
||||
1. Run the following, you will get an Open Shift instance installed with
|
||||
Isso:
|
||||
|
||||
::
|
||||
|
||||
rhc create-app appname python-2.7 --from-code https://github.com/avinassh/isso-openshift.git
|
||||
|
||||
2. Above step also clones Git repository of your Open Shift instance, in
|
||||
current directory. Make changes to the configuration file and push
|
||||
back to Openshift, it will be redeployed with new settings.
|
||||
|
||||
3. Visit ``http://<yourappname>-<openshift-namespace>.com/info`` to
|
||||
verify Isso is deployed properly and is working.
|
||||
|
||||
.. _Isso Openshift Deployment Kit: https://github.com/avinassh/isso-openshift
|
||||
.. _instructions: https://developers.openshift.com/en/managing-client-tools.html
|
||||
|
@ -2,7 +2,7 @@ Overview
|
||||
========
|
||||
|
||||
Welcome to Isso's documentation. This documentation will help you get started
|
||||
fast. If you run into any problems when using Isso, you can find the answer in
|
||||
fast. If you get any problems when using Isso, you can find the answer in
|
||||
troubleshooting guide or you can ask me on IRC or GitHub.
|
||||
|
||||
Documentation overview:
|
||||
|
@ -1,17 +1,15 @@
|
||||
Installation
|
||||
============
|
||||
|
||||
Isso is a web application written in Python. If pip and virtualenv mean anything
|
||||
to you, continue with :ref:`install-from-pypi`. If you are running
|
||||
Debian/Ubuntu, Gentoo, Archlinux or Fedora, you can use
|
||||
:ref:`prebuilt-package`. If not, read the next section carefully.
|
||||
Isso is a web application written in Python. If pip and virtualenv mean
|
||||
anything to you, continue with :ref:`install-from-pypi`. If you are running
|
||||
Debian/Ubuntu or Gentoo, you can use :ref:`prebuilt-package`. If not, read the
|
||||
next section carefully.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
:depth: 1
|
||||
|
||||
.. _install-interludium:
|
||||
|
||||
Interludium: Python is not PHP
|
||||
------------------------------
|
||||
|
||||
@ -22,12 +20,12 @@ libraries, but most likely not all required by Isso (or in an up-to-date
|
||||
version – looking at you, Debian!).
|
||||
|
||||
That's why most Python developers use the `Python Package Index`_ to get their
|
||||
dependencies. The most important rule to follow is to never install *anything* from PyPi
|
||||
dependencies. But the most important rule: never install *anything* from PyPi
|
||||
as root. Not because of malicious software, but because you *will* break your
|
||||
system.
|
||||
``easy_install`` is one tool to mess up your system. Another package manager is
|
||||
``pip``. If you ever searched for an issue with Python/pip and Stackoverflow is
|
||||
suggesting you ``easy_install pip`` or ``pip install --upgrade pip`` (as root
|
||||
suggesting your ``easy_install pip`` or ``pip install --upgrade pip`` (as root
|
||||
of course!), you are doing it wrong. `Why you should not use Python's
|
||||
easy_install carelessly on Debian`_ is worth the read.
|
||||
|
||||
@ -39,18 +37,18 @@ package manager.
|
||||
.. code-block:: sh
|
||||
|
||||
# for Debian/Ubuntu
|
||||
~> sudo apt-get install python-setuptools python-virtualenv python-dev
|
||||
~> sudo apt-get install python-setuptools python-virtualenv
|
||||
|
||||
# Fedora/Red Hat
|
||||
~> sudo yum install python-setuptools python-virtualenv python-devel
|
||||
~> sudo yum install python-setuptools python-virtualenv
|
||||
|
||||
The next steps should be done as regular user, not as root (although possible
|
||||
but not recommended):
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
~> virtualenv /opt/isso
|
||||
~> source /opt/isso/bin/activate
|
||||
~> virtualenv /path/to/isso
|
||||
~> source /path/to/isso/bin/activate
|
||||
|
||||
After calling `source`, you can now install packages from PyPi locally into this
|
||||
virtual environment. If you don't like Isso anymore, you just `rm -rf` the
|
||||
@ -58,11 +56,6 @@ folder. Inside this virtual environment, you may also execute the example
|
||||
commands from above to upgrade your Python Package Manager (although it barely
|
||||
makes sense), it is completely independent from your global system.
|
||||
|
||||
To use Isso installed in a virtual environment outside of the virtual
|
||||
environment, you just need to add */opt/isso/bin* to your :envvar:`PATH` or
|
||||
execute */opt/isso/bin/isso* directly. It will launch Isso from within the
|
||||
virtual environment.
|
||||
|
||||
With a virtualenv active, you may now continue to :ref:`install-from-pypi`!
|
||||
Of course you may not need a virtualenv when you are running dedicated virtual
|
||||
machines or a shared host (e.g. Uberspace.de).
|
||||
@ -79,7 +72,7 @@ Install from PyPi
|
||||
Requirements
|
||||
^^^^^^^^^^^^
|
||||
|
||||
- Python 2.7 or 3.4+ (+ devel headers)
|
||||
- Python 2.7, 3.3 or 3.4+ (+ devel headers)
|
||||
- SQLite 3.3.8 or later
|
||||
- a working C compiler
|
||||
|
||||
@ -117,7 +110,7 @@ For easier execution, you can symlink the executable to a location in your
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
~> ln -s /opt/isso/bin/isso /usr/local/bin/isso
|
||||
~> ln -s /path/to/isso-venv/bin/isso /usr/local/bin/isso
|
||||
|
||||
Upgrade
|
||||
^^^^^^^
|
||||
@ -126,7 +119,7 @@ To upgrade Isso, activate your virtual environment again, and run
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
~> source /opt/isso/bin/activate # optional
|
||||
~> source /path/to/isso/bin/activate # optional
|
||||
~> pip install --upgrade isso
|
||||
|
||||
.. _prebuilt-package:
|
||||
@ -134,7 +127,11 @@ To upgrade Isso, activate your virtual environment again, and run
|
||||
Prebuilt Packages
|
||||
-----------------
|
||||
|
||||
* Debian (since Buster): https://packages.debian.org/search?keywords=isso
|
||||
* Debian: https://packages.crapouillou.net/ – built from PyPi. Includes
|
||||
startup scripts and vhost configurations for Lighttpd, Apache and Nginx
|
||||
[`source <https://github.com/jgraichen/debian-isso>`__].
|
||||
`#729218 <https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=729218>`_ is an
|
||||
ITP for Debian.
|
||||
|
||||
* Gentoo: http://eroen.eu/cgit/cgit.cgi/eroen-overlay/tree/www-apps/isso?h=isso
|
||||
– not yet available in Portage, but you can use the ebuild to build Isso.
|
||||
@ -142,29 +139,13 @@ Prebuilt Packages
|
||||
* Arch Linux: https://aur.archlinux.org/packages/isso/
|
||||
– install with `yaourt isso`.
|
||||
|
||||
* Fedora: https://copr.fedoraproject.org/coprs/jujens/isso/ — copr
|
||||
repository. Built from Pypi, includes a systemctl unit script.
|
||||
|
||||
Build a Docker image
|
||||
--------------------
|
||||
|
||||
You can get a Docker image by running ``docker build . -t
|
||||
isso``. Assuming you have your configuration in ``/opt/isso``, you can
|
||||
use the following command to spawn the Docker container:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
~> docker run -d --rm --name isso -p 127.0.0.1:8080:8080 -v /opt/isso:/config -v /opt/isso:/db isso
|
||||
|
||||
Then, you can use a reverse proxy to expose port 8080.
|
||||
|
||||
Install from Source
|
||||
-------------------
|
||||
|
||||
If you want to hack on Isso or track down issues, there's an alternate
|
||||
way to set up Isso. It requires a lot more dependencies and effort:
|
||||
|
||||
- Python 2.7 or 3.4+ (+ devel headers)
|
||||
- Python 2.6, 2.7 or 3.3+ (+ devel headers)
|
||||
- Virtualenv
|
||||
- SQLite 3.3.8 or later
|
||||
- a working C compiler
|
||||
@ -197,7 +178,7 @@ Install JavaScript modules:
|
||||
|
||||
~> make init
|
||||
|
||||
Integration without optimization:
|
||||
Integration without previous optimization:
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
@ -208,7 +189,7 @@ Optimization:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
~> npm install -g requirejs uglify-js jade
|
||||
~> npm install -g requirejs uglifyjs jade
|
||||
~> make js
|
||||
|
||||
.. _init-scripts:
|
||||
@ -217,58 +198,10 @@ Init scripts
|
||||
------------
|
||||
|
||||
Init scripts to run Isso as a service (check your distribution's documentation
|
||||
for your init-system; e.g. Debian uses SysVinit, Fedora uses systemd) if you
|
||||
for your init-system; e.g. Debian uses SysVinit, Fedora uses SystemD) if you
|
||||
don't use FastCGi or uWSGI:
|
||||
|
||||
- systemd (Isso + Gunicorn): https://github.com/jgraichen/debian-isso/blob/master/debian/isso.service
|
||||
- SysVinit (Isso + Gunicorn): https://github.com/jgraichen/debian-isso/blob/master/debian/isso.init
|
||||
- SystemD: https://github.com/jgraichen/debian-isso/blob/master/debian/isso.service
|
||||
- SysVinit: https://github.com/jgraichen/debian-isso/blob/master/debian/isso.init
|
||||
- OpenBSD: https://gist.github.com/noqqe/7397719
|
||||
- FreeBSD: https://gist.github.com/ckoepp/52f6f0262de04cee1b88ef4a441e276d
|
||||
- Supervisor: https://github.com/posativ/isso/issues/47
|
||||
|
||||
If you're writing your own init script, you can utilize ``start-stop-daemon``
|
||||
to run Isso in the background (Isso runs in the foreground usually). Below you
|
||||
will find a very basic SysVinit script which you can use for inspiration:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
#!/bin/sh
|
||||
### BEGIN INIT INFO
|
||||
# Provides: isso
|
||||
# Required-Start: $local_fs $network
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Description: lightweight Disqus alternative
|
||||
### END INIT INFO
|
||||
|
||||
EXEC=/opt/isso/bin/isso
|
||||
EXEC_OPTS="-c /etc/isso.cfg run"
|
||||
|
||||
RUNAS=isso
|
||||
PIDFILE=/var/run/isso.pid
|
||||
|
||||
start() {
|
||||
echo 'Starting service…' >&2
|
||||
start-stop-daemon --start --user "$RUNAS" --background --make-pidfile --pidfile $PIDFILE \
|
||||
--exec $EXEC -- $EXEC_OPTS
|
||||
}
|
||||
|
||||
stop() {
|
||||
echo 'Stopping service…' >&2
|
||||
start-stop-daemon --stop --user "$RUNAS" --pidfile $PIDFILE --exec $EXEC
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
start
|
||||
;;
|
||||
stop)
|
||||
stop
|
||||
;;
|
||||
restart)
|
||||
stop
|
||||
start
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart}"
|
||||
esac
|
||||
|
@ -6,7 +6,7 @@ What's Isso?
|
||||
|
||||
Isso is a lightweight commenting server similar to Disqus. It allows anonymous
|
||||
comments, maintains identity and is simple to administrate. It uses JavaScript
|
||||
and cross-origin resource sharing for easy integration into static websites.
|
||||
and cross-origin ressource sharing for easy integration into static websites.
|
||||
|
||||
No, I meant "Isso"
|
||||
------------------
|
||||
|
@ -2,12 +2,13 @@ Quickstart
|
||||
==========
|
||||
|
||||
Assuming you have successfully :doc:`installed <install>` Isso, here's
|
||||
a quickstart guide that covers the most common setup. Sections covered:
|
||||
a quickstart quide that covers the most common setup. Sections covered:
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
:depth: 1
|
||||
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
@ -18,8 +19,7 @@ sane defaults.
|
||||
.. code-block:: ini
|
||||
|
||||
[general]
|
||||
; database location, check permissions, automatically created if it
|
||||
does not exist
|
||||
; database location, check permissions, automatically created if not exists
|
||||
dbpath = /var/lib/isso/comments.db
|
||||
; your website or blog (not the location of Isso!)
|
||||
host = http://example.tld/
|
||||
@ -32,7 +32,7 @@ sane defaults.
|
||||
https://example.tld/
|
||||
|
||||
Note, that multiple, *different* websites are **not** supported in a single
|
||||
configuration. To serve comments for different websites, refer to
|
||||
configuration. To serve comments for diffent websites, refer to
|
||||
:ref:`Multiple Sites <configure-multiple-sites>`.
|
||||
|
||||
The moderation is done with signed URLs sent by email or logged to stdout.
|
||||
@ -45,7 +45,7 @@ enable moderation queue, add:
|
||||
enabled = true
|
||||
|
||||
To moderate comments, either use the activation or deletion URL in the logs or
|
||||
:ref:`use SMTP <configure-smtp>` to get notified of new comments, including the
|
||||
:ref:`use SMTP <configure-smtp>` to get notified on new comments including the
|
||||
URLs for activation and deletion:
|
||||
|
||||
.. code-block:: ini
|
||||
@ -61,23 +61,21 @@ For more options, see :doc:`server <configuration/server>` and :doc:`client
|
||||
Migration
|
||||
---------
|
||||
|
||||
Isso provides a tool for importing comments from Disqus_ or WordPress_.
|
||||
You can also import comments from any other comment system, but this topic is more
|
||||
complex and is covered in :doc:`advanced migration <extras/advanced-migration>`.
|
||||
You can import comments from Disqus_ or WordPress_.
|
||||
|
||||
To export your comments from Disqus, log into Disqus, go to your website, click
|
||||
on *Discussions* and select the *Export* tab. You'll receive an email with your
|
||||
comments. Unfortunately, Disqus does not export up- and downvotes.
|
||||
|
||||
To export comments from your previous WordPress installation, go to *Tools*,
|
||||
export your data. It has been reported that WordPress may generate broken XML.
|
||||
Try to repair the file using ``xmllint`` before you continue with the import.
|
||||
export your data. WordPress WXR import is quite new and may not work for you;
|
||||
please report any failures.
|
||||
|
||||
Now import the XML dump:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
~> isso -c /path/to/isso.cfg import -t [disqus|wordpress] disqus-or-wordpress.xml
|
||||
~> isso -c /path/to/isso.cfg import disqus-or-wordpress.xml
|
||||
[100%] 53 threads, 192 comments
|
||||
|
||||
.. _Disqus: https://disqus.com/
|
||||
@ -141,7 +139,7 @@ a comment to see if the setup works. If not, see :doc:`troubleshooting`.
|
||||
Going Further
|
||||
-------------
|
||||
|
||||
There are several server and client configuration options not covered in this
|
||||
There are several server and client configuration options uncovered in this
|
||||
quickstart, check out :doc:`configuration/server` and
|
||||
:doc:`configuration/client` for more information. For further website
|
||||
integration, see :doc:`extras/advanced-integration`.
|
||||
|
@ -5,8 +5,8 @@ Multiple Sites
|
||||
--------------
|
||||
|
||||
Isso is designed to serve comments for a single website and therefore stores
|
||||
comments for a relative URL. This is done to support HTTP, HTTPS and even domain transfers
|
||||
without manual intervention. You can chain Isso to support multiple
|
||||
comments for a relative URL to support HTTP, HTTPS and even domain transfers
|
||||
without manual intervention. But you can chain Isso to support multiple
|
||||
websites on different domains.
|
||||
|
||||
The following example uses `gunicorn <http://gunicorn.org/>`_ as WSGI server (
|
||||
|
@ -1,39 +1,4 @@
|
||||
Troubleshooting
|
||||
===============
|
||||
|
||||
For uberspace users
|
||||
-------------------
|
||||
Some uberspace users experienced problems with isso and they solved their
|
||||
issues by adding `DirectoryIndex disabled` as the first line in the `.htaccess`
|
||||
file for the domain the isso server is running on.
|
||||
|
||||
pkg_ressources.DistributionNotFound
|
||||
-----------------------------------
|
||||
|
||||
This is usually caused by messing up the system's Python with newer packages
|
||||
from PyPi (e.g. by executing `easy_install --upgrade pip` as root) and is not
|
||||
related to Isso at all.
|
||||
|
||||
Install Isso in a virtual environment as described in
|
||||
:ref:`install-interludium`. Alternatively, you can use `pip install --user`
|
||||
to install Isso into the user's home.
|
||||
|
||||
UnicodeDecodeError: 'ascii' codec can't decode byte 0xff
|
||||
--------------------------------------------------------
|
||||
|
||||
Likely an issue with your environment, check you set your preferred file
|
||||
encoding either in :envvar:`LANG`, :envvar:`LANGUAGE`, :envvar:`LC_ALL` or
|
||||
:envvar:`LC_CTYPE`:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
$ env LANG=C.UTF-8 isso [-h] [--version] ...
|
||||
|
||||
If none of the mentioned variables are set, the interaction with Isso will
|
||||
likely fail (unable to print non-ascii characters to stdout/err, unable to
|
||||
parse configuration file with non-ascii characters and so forth).
|
||||
|
||||
The web console shows 404 Not Found responses
|
||||
---------------------------------------------
|
||||
|
||||
That's fine. Isso returns "404 Not Found" to indicate "No comments".
|
||||
To be written.
|
||||
|
25
docs/faq.rst
25
docs/faq.rst
@ -1,17 +1,32 @@
|
||||
Frequently asked question
|
||||
=========================
|
||||
|
||||
Why not use Gravatar/Libravatar/... ?
|
||||
-------------------------------------
|
||||
|
||||
Various people asked or complained about the generated icons next to their
|
||||
comments. First, it is not an avatar, it is an identicon used to
|
||||
*identify* an author of multiple comments without leaking personal
|
||||
informations (unlike Gravatar).
|
||||
|
||||
If you are in need of Gravatar_, then use Disqus. If you run your own
|
||||
Libravatar_ server, you can work on a patch for Isso which adds *optional*
|
||||
support for avatars.
|
||||
|
||||
.. _Gravatar: https://secure.gravatar.com/
|
||||
.. _Libravatar: http://libravatar.org/
|
||||
|
||||
Why SQLite3?
|
||||
------------
|
||||
|
||||
Although partially answered on the index page, here is a more complete answer: If
|
||||
Although partially answered on the index page, here a more complete answer: If
|
||||
you manage massive amounts of comments, Isso is a really bad choice. Isso is
|
||||
designed to be simple and easy to setup, it is not optimized for high-traffic
|
||||
designed to be simple and easy to setup, not optimizied for high-traffic
|
||||
websites (use a `dedicated Disqus`_ instance then).
|
||||
|
||||
Comments are not big data.
|
||||
comments are not big data
|
||||
|
||||
For example, if you have 209 threads and 778 comments in total this only needs 620 kilobytes
|
||||
of memory. This is an excellent use case for SQLite.
|
||||
For example, 209 threads and 778 comments in total only need 620K (kilobyte)
|
||||
memory. Excellent use case for SQLite.
|
||||
|
||||
.. _dedicated Disqus:
|
||||
|
@ -1,10 +0,0 @@
|
||||
Releasing steps
|
||||
===============
|
||||
|
||||
* Run ``python3 setup.py nosetests``, ``python2 setup.py nosetests``
|
||||
* Update version number in ``setup.py`` and ``CHANGES.rst``
|
||||
* ``git commit -m "Preparing ${VERSION}" setup.py CHANGES.rst``
|
||||
* ``git tag -as ${VERSION}``
|
||||
* ``make init all``
|
||||
* ``python3 setup.py sdist``
|
||||
* ``twine upload --sign dist/isso-${VERSION}.tar.gz``
|
205
isso/__init__.py
205
isso/__init__.py
@ -35,46 +35,43 @@ import sys
|
||||
|
||||
if sys.argv[0].startswith("isso"):
|
||||
try:
|
||||
import gevent.monkey
|
||||
gevent.monkey.patch_all()
|
||||
import gevent.monkey; gevent.monkey.patch_all()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
import os
|
||||
import errno
|
||||
import atexit
|
||||
import logging
|
||||
import tempfile
|
||||
|
||||
from os.path import dirname, join
|
||||
from argparse import ArgumentParser
|
||||
from functools import partial, reduce
|
||||
|
||||
import pkg_resources
|
||||
werkzeug = pkg_resources.get_distribution("werkzeug")
|
||||
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
|
||||
from werkzeug.routing import Map
|
||||
from werkzeug.routing import Map, Rule, redirect
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError
|
||||
|
||||
from werkzeug.middleware.shared_data import SharedDataMiddleware
|
||||
from werkzeug.wsgi import SharedDataMiddleware
|
||||
from werkzeug.local import Local, LocalManager
|
||||
from werkzeug.serving import run_simple
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from werkzeug.middleware.profiler import ProfilerMiddleware
|
||||
from werkzeug.contrib.fixers import ProxyFix
|
||||
from werkzeug.contrib.profiler import ProfilerMiddleware
|
||||
|
||||
local = Local()
|
||||
local_manager = LocalManager([local])
|
||||
|
||||
from isso import config, db, migrate, wsgi, ext, views
|
||||
from isso.core import ThreadedMixin, ProcessMixin, uWSGIMixin
|
||||
try:
|
||||
import uwsgi
|
||||
except ImportError:
|
||||
uwsgi = None
|
||||
|
||||
from isso import cache, config, db, migrate, ext, queue, spam, views, wsgi
|
||||
from isso.wsgi import origin, urlsplit
|
||||
from isso.utils import http, JSONRequest, html, hash
|
||||
from isso.views import comments
|
||||
from isso.utils import http, JSONRequest, html, hash, URLSafeTimedSerializer
|
||||
|
||||
from isso.ext.notifications import Stdout, SMTP
|
||||
|
||||
logging.getLogger('werkzeug').setLevel(logging.WARN)
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s: %(message)s")
|
||||
@ -82,61 +79,72 @@ logging.basicConfig(
|
||||
logger = logging.getLogger("isso")
|
||||
|
||||
|
||||
class ProxyFixCustom(ProxyFix):
|
||||
def __init__(self, app):
|
||||
# This is needed for werkzeug.wsgi.get_current_url called in isso/views/comments.py
|
||||
# to work properly when isso is hosted under a sub-path
|
||||
# cf. https://werkzeug.palletsprojects.com/en/1.0.x/middleware/proxy_fix/
|
||||
super().__init__(app, x_prefix=1)
|
||||
|
||||
|
||||
class Isso(object):
|
||||
|
||||
def __init__(self, conf):
|
||||
def __init__(self, conf, cacheobj=None, dbobj=None):
|
||||
|
||||
if cacheobj is None:
|
||||
cacheobj = cache.Cache(1024)
|
||||
|
||||
if dbobj is None:
|
||||
dbobj = db.Adapter("sqlite:///:memory:")
|
||||
|
||||
self.conf = conf
|
||||
self.db = db.SQLite3(conf.get('general', 'dbpath'), conf)
|
||||
self.signer = URLSafeTimedSerializer(
|
||||
self.db.preferences.get("session-key"))
|
||||
self.markup = html.Markup(conf.section('markup'))
|
||||
self.hasher = hash.new(conf.section("hash"))
|
||||
self.db = dbobj
|
||||
|
||||
super(Isso, self).__init__(conf)
|
||||
signer = URLSafeTimedSerializer(
|
||||
dbobj.preferences.get("session-key"))
|
||||
markup = html.Markup(
|
||||
conf.getlist("markup", "options"),
|
||||
conf.getlist("markup", "allowed-elements"),
|
||||
conf.getlist("markup", "allowed-attributes"))
|
||||
hasher = hash.new(
|
||||
conf.get("hash", "algorithm"),
|
||||
conf.get("hash", "salt"))
|
||||
guard = spam.Guard(
|
||||
dbobj,
|
||||
conf.getboolean("guard", "enabled"),
|
||||
conf.getint("guard", "ratelimit"),
|
||||
conf.getint("guard", "direct-reply"),
|
||||
conf.getboolean("guard", "reply-to-self"),
|
||||
conf.getint("general", "max-age"))
|
||||
|
||||
subscribers = []
|
||||
smtp_backend = False
|
||||
for backend in conf.getlist("general", "notify"):
|
||||
if backend == "stdout":
|
||||
subscribers.append(Stdout(None))
|
||||
elif backend in ("smtp", "SMTP"):
|
||||
smtp_backend = True
|
||||
else:
|
||||
logger.warn("unknown notification backend '%s'", backend)
|
||||
if smtp_backend or conf.getboolean("general", "reply-notifications"):
|
||||
subscribers.append(SMTP(self))
|
||||
urls = Map()
|
||||
Isso.routes(
|
||||
urls,
|
||||
views.API(conf, cacheobj, dbobj, guard, hasher.uhash, markup, signer),
|
||||
views.Info(conf))
|
||||
|
||||
self.signal = ext.Signal(*subscribers)
|
||||
self.urls = urls
|
||||
|
||||
self.urls = Map()
|
||||
@classmethod
|
||||
def routes(cls, urls, api, info):
|
||||
|
||||
views.Info(self)
|
||||
comments.API(self, self.hasher)
|
||||
for rule in [
|
||||
Rule("/demo/", endpoint=lambda *z: redirect("/demo/index.html")),
|
||||
Rule("/info", endpoint=info.show)
|
||||
]:
|
||||
urls.add(rule)
|
||||
|
||||
def render(self, text):
|
||||
return self.markup.render(text)
|
||||
|
||||
def sign(self, obj):
|
||||
return self.signer.dumps(obj)
|
||||
|
||||
def unsign(self, obj, max_age=None):
|
||||
return self.signer.loads(obj, max_age=max_age or self.conf.getint('general', 'max-age'))
|
||||
for func, (method, rule) in [
|
||||
('fetch', ('GET', '/')),
|
||||
('new', ('POST', '/new')),
|
||||
('count', ('POST', '/count')),
|
||||
('view', ('GET', '/id/<int:id>')),
|
||||
('edit', ('PUT', '/id/<int:id>')),
|
||||
('delete', ('DELETE', '/id/<int:id>')),
|
||||
('moderate',('GET', '/id/<int:id>/<any(activate,delete):action>/<string:key>')),
|
||||
('moderate',('POST', '/id/<int:id>/<any(activate,delete):action>/<string:key>')),
|
||||
('like', ('POST', '/id/<int:id>/like')),
|
||||
('dislike', ('POST', '/id/<int:id>/dislike')),
|
||||
]:
|
||||
urls.add(Rule(rule, methods=[method], endpoint=getattr(api, func)))
|
||||
|
||||
def dispatch(self, request):
|
||||
local.request = request
|
||||
|
||||
local.host = wsgi.host(request.environ)
|
||||
local.origin = origin(self.conf.getiter(
|
||||
"general", "host"))(request.environ)
|
||||
local.origin = origin(self.conf.getiter("general", "host"))(request.environ)
|
||||
|
||||
adapter = self.urls.bind_to_environ(request.environ)
|
||||
|
||||
@ -150,8 +158,7 @@ class Isso(object):
|
||||
except HTTPException as e:
|
||||
return e
|
||||
except Exception:
|
||||
logger.exception("%s %s", request.method,
|
||||
request.environ["PATH_INFO"])
|
||||
logger.exception("%s %s", request.method, request.environ["PATH_INFO"])
|
||||
return InternalServerError()
|
||||
else:
|
||||
return response
|
||||
@ -164,22 +171,25 @@ class Isso(object):
|
||||
return self.wsgi_app(environ, start_response)
|
||||
|
||||
|
||||
def make_app(conf=None, threading=True, multiprocessing=False, uwsgi=False):
|
||||
def make_app(conf):
|
||||
|
||||
if not any((threading, multiprocessing, uwsgi)):
|
||||
raise RuntimeError("either set threading, multiprocessing or uwsgi")
|
||||
dbobj = db.Adapter(conf.get("general", "dbpath"))
|
||||
|
||||
if threading:
|
||||
class App(Isso, ThreadedMixin):
|
||||
pass
|
||||
elif multiprocessing:
|
||||
class App(Isso, ProcessMixin):
|
||||
pass
|
||||
if uwsgi is not None:
|
||||
cacheobj = cache.uWSGICache(timeout=3600)
|
||||
else:
|
||||
class App(Isso, uWSGIMixin):
|
||||
pass
|
||||
cacheobj = cache.SQLite3Cache(db.SQLite3("/dev/shm/isso"), threshold=2048)
|
||||
|
||||
isso = App(conf)
|
||||
jobs = queue.Jobs()
|
||||
jobs.register("db-purge", dbobj, conf.getint("moderation", "purge-after"))
|
||||
|
||||
queueobj = queue.Queue(1024)
|
||||
worker = queue.Worker(queueobj, jobs)
|
||||
|
||||
isso = Isso(conf, cacheobj, dbobj)
|
||||
|
||||
atexit.register(worker.join, 0.25)
|
||||
worker.start()
|
||||
|
||||
# check HTTP server connection
|
||||
for host in conf.getiter("general", "host"):
|
||||
@ -194,26 +204,22 @@ def make_app(conf=None, threading=True, multiprocessing=False, uwsgi=False):
|
||||
|
||||
wrapper = [local_manager.make_middleware]
|
||||
|
||||
if isso.conf.getboolean("server", "profile"):
|
||||
if conf.getboolean("server", "profile"):
|
||||
wrapper.append(partial(ProfilerMiddleware,
|
||||
sort_by=("cumulative", ), restrictions=("isso/(?!lib)", 10)))
|
||||
sort_by=("cumulative", ), restrictions=("isso/(?!lib)", 10)))
|
||||
|
||||
wrapper.append(partial(SharedDataMiddleware, exports={
|
||||
'/js': join(dirname(__file__), 'js/'),
|
||||
'/css': join(dirname(__file__), 'css/'),
|
||||
'/img': join(dirname(__file__), 'img/'),
|
||||
'/demo': join(dirname(__file__), 'demo/')
|
||||
}))
|
||||
|
||||
wrapper.append(partial(wsgi.CORSMiddleware,
|
||||
origin=origin(isso.conf.getiter("general", "host")),
|
||||
allowed=("Origin", "Referer", "Content-Type"),
|
||||
exposed=("X-Set-Cookie", "Date")))
|
||||
origin=origin(conf.getiter("general", "host")),
|
||||
allowed=("Origin", "Referer", "Content-Type"),
|
||||
exposed=("X-Set-Cookie", "Date")))
|
||||
|
||||
wrapper.extend([wsgi.SubURI, ProxyFixCustom])
|
||||
|
||||
if werkzeug.version.startswith("0.8"):
|
||||
wrapper.append(wsgi.LegacyWerkzeugMiddleware)
|
||||
wrapper.extend([wsgi.SubURI, ProxyFix])
|
||||
|
||||
return reduce(lambda x, f: f(x), wrapper, isso)
|
||||
|
||||
@ -223,8 +229,7 @@ def main():
|
||||
parser = ArgumentParser(description="a blog comment hosting service")
|
||||
subparser = parser.add_subparsers(help="commands", dest="command")
|
||||
|
||||
parser.add_argument('--version', action='version',
|
||||
version='%(prog)s ' + dist.version)
|
||||
parser.add_argument('--version', action='version', version='%(prog)s ' + dist.version)
|
||||
parser.add_argument("-c", dest="conf", default="/etc/isso.conf",
|
||||
metavar="/etc/isso.conf", help="set configuration file")
|
||||
|
||||
@ -233,51 +238,39 @@ def main():
|
||||
imprt.add_argument("-n", "--dry-run", dest="dryrun", action="store_true",
|
||||
help="perform a trial run with no changes made")
|
||||
imprt.add_argument("-t", "--type", dest="type", default=None,
|
||||
choices=["disqus", "wordpress", "generic"], help="export type")
|
||||
imprt.add_argument("--empty-id", dest="empty_id", action="store_true",
|
||||
help="workaround for weird Disqus XML exports, #135")
|
||||
choices=["disqus", "wordpress"], help="export type")
|
||||
|
||||
# run Isso as stand-alone server
|
||||
subparser.add_parser("run", help="run server")
|
||||
serve = subparser.add_parser("run", help="run server")
|
||||
|
||||
args = parser.parse_args()
|
||||
conf = config.load(
|
||||
join(dist.location, dist.project_name, "defaults.ini"), args.conf)
|
||||
conf = config.load(join(dist.location, "isso", "defaults.ini"), args.conf)
|
||||
|
||||
if args.command == "import":
|
||||
conf.set("guard", "enabled", "off")
|
||||
|
||||
if args.dryrun:
|
||||
xxx = tempfile.NamedTemporaryFile()
|
||||
dbpath = xxx.name
|
||||
dbpath = ":memory:"
|
||||
else:
|
||||
dbpath = conf.get("general", "dbpath")
|
||||
|
||||
mydb = db.SQLite3(dbpath, conf)
|
||||
migrate.dispatch(args.type, mydb, args.dump, args.empty_id)
|
||||
mydb = db.Adapter(db.SQLite3(dbpath), conf)
|
||||
migrate.dispatch(args.type, mydb, args.dump)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
if conf.get("general", "log-file"):
|
||||
handler = logging.FileHandler(conf.get("general", "log-file"))
|
||||
|
||||
logger.addHandler(handler)
|
||||
logging.getLogger("werkzeug").addHandler(handler)
|
||||
|
||||
logger.propagate = False
|
||||
logging.getLogger("werkzeug").propagate = False
|
||||
|
||||
if not any(conf.getiter("general", "host")):
|
||||
logger.error("No website(s) configured, Isso won't work.")
|
||||
sys.exit(1)
|
||||
|
||||
app = make_app(conf)
|
||||
|
||||
if conf.get("server", "listen").startswith("http://"):
|
||||
host, port, _ = urlsplit(conf.get("server", "listen"))
|
||||
try:
|
||||
from gevent.pywsgi import WSGIServer
|
||||
WSGIServer((host, port), make_app(conf)).serve_forever()
|
||||
WSGIServer((host, port), app).serve_forever()
|
||||
except ImportError:
|
||||
run_simple(host, port, make_app(conf), threaded=True,
|
||||
run_simple(host, port, app, threaded=True,
|
||||
use_reloader=conf.getboolean('server', 'reload'))
|
||||
else:
|
||||
sock = conf.get("server", "listen").partition("unix://")[2]
|
||||
@ -286,4 +279,4 @@ def main():
|
||||
except OSError as ex:
|
||||
if ex.errno != errno.ENOENT:
|
||||
raise
|
||||
wsgi.SocketHTTPServer(sock, make_app(conf)).serve_forever()
|
||||
wsgi.SocketHTTPServer(sock, app).serve_forever()
|
||||
|
105
isso/cache/__init__.py
vendored
Normal file
105
isso/cache/__init__.py
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import abc
|
||||
import json
|
||||
|
||||
from isso.utils import types
|
||||
from isso.compat import string_types
|
||||
|
||||
|
||||
def pickle(value):
|
||||
return json.dumps(value).encode("utf-8")
|
||||
|
||||
|
||||
def unpickle(value):
|
||||
return json.loads(value.decode("utf-8"))
|
||||
|
||||
|
||||
class Base(object):
|
||||
"""Base class for all cache objects.
|
||||
|
||||
Arbitrary values are set by namespace and key. Namespace and key must be
|
||||
strings, the underlying cache implementation may use :func:`pickle` and
|
||||
:func:`unpickle:` to safely un-/serialize Python primitives.
|
||||
|
||||
:param threshold: maximum size of the cache
|
||||
:param timeout: key expiration
|
||||
"""
|
||||
|
||||
__metaclass__ = abc.ABCMeta
|
||||
|
||||
# enable serialization of Python primitives
|
||||
serialize = False
|
||||
|
||||
def __init__(self, threshold, timeout):
|
||||
self.threshold = threshold
|
||||
self.timeout = timeout
|
||||
|
||||
def get(self, ns, key, default=None):
|
||||
types.require(ns, string_types)
|
||||
types.require(key, string_types)
|
||||
|
||||
try:
|
||||
value = self._get(ns.encode("utf-8"), key.encode("utf-8"))
|
||||
except KeyError:
|
||||
return default
|
||||
else:
|
||||
if self.serialize:
|
||||
value = unpickle(value)
|
||||
return value
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get(self, ns, key):
|
||||
return
|
||||
|
||||
def set(self, ns, key, value):
|
||||
types.require(ns, string_types)
|
||||
types.require(key, string_types)
|
||||
|
||||
if self.serialize:
|
||||
value = pickle(value)
|
||||
|
||||
return self._set(ns.encode("utf-8"), key.encode("utf-8"), value)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _set(self, ns, key, value):
|
||||
return
|
||||
|
||||
def delete(self, ns, key):
|
||||
types.require(ns, string_types)
|
||||
types.require(key, string_types)
|
||||
|
||||
return self._delete(ns.encode("utf-8"), key.encode("utf-8"))
|
||||
|
||||
@abc.abstractmethod
|
||||
def _delete(self, ns, key):
|
||||
return
|
||||
|
||||
|
||||
class Cache(Base):
|
||||
"""Implements a simple in-memory cache; once the threshold is reached, all
|
||||
cached elements are discarded (the timeout parameter is ignored).
|
||||
"""
|
||||
|
||||
def __init__(self, threshold=512, timeout=-1):
|
||||
super(Cache, self).__init__(threshold, timeout)
|
||||
self.cache = {}
|
||||
|
||||
def _get(self, ns, key):
|
||||
return self.cache[ns + b'-' + key]
|
||||
|
||||
def _set(self, ns, key, value):
|
||||
if len(self.cache) > self.threshold - 1:
|
||||
self.cache.clear()
|
||||
self.cache[ns + b'-' + key] = value
|
||||
|
||||
def _delete(self, ns, key):
|
||||
self.cache.pop(ns + b'-' + key, None)
|
||||
|
||||
|
||||
from .uwsgi import uWSGICache
|
||||
from .sqlite import SQLite3Cache
|
||||
|
||||
__all__ = ["Cache", "SQLite3Cache", "uWSGICache"]
|
57
isso/cache/sqlite.py
vendored
Normal file
57
isso/cache/sqlite.py
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import time
|
||||
|
||||
from . import Base
|
||||
|
||||
|
||||
class SQLite3Cache(Base):
|
||||
"""Implements a shared cache using SQLite3. Works across multiple processes
|
||||
and threads, concurrent writes are not supported.
|
||||
|
||||
JSON is used to serialize python primitives in a safe way.
|
||||
"""
|
||||
|
||||
serialize = True
|
||||
|
||||
def __init__(self, connection, threshold=1024, timeout=-1):
|
||||
super(SQLite3Cache, self).__init__(threshold, timeout)
|
||||
self.connection = connection
|
||||
self.connection.execute(
|
||||
'CREATE TABLE IF NOT EXISTS cache ('
|
||||
' key TEXT PRIMARY KEY,'
|
||||
' value BLOB,'
|
||||
' time FLOAT)')
|
||||
|
||||
# drop trigger, in case threshold has changed
|
||||
self.connection.execute('DROP TRIGGER IF EXISTS sweeper')
|
||||
self.connection.execute([
|
||||
'CREATE TRIGGER sweeper AFTER INSERT ON cache',
|
||||
'BEGIN',
|
||||
' DELETE FROM cache WHERE key NOT IN ('
|
||||
' SELECT key FROM cache',
|
||||
' ORDER BY time DESC LIMIT {0}'.format(threshold),
|
||||
' );',
|
||||
'END'])
|
||||
|
||||
def _get(self, ns, key, default=None):
|
||||
rv = self.connection.execute(
|
||||
'SELECT value FROM cache WHERE key = ?',
|
||||
(ns + b'-' + key, )).fetchone()
|
||||
|
||||
if rv is None:
|
||||
raise KeyError
|
||||
|
||||
return rv[0]
|
||||
|
||||
def _set(self, ns, key, value):
|
||||
with self.connection.transaction as con:
|
||||
con.execute(
|
||||
'INSERT OR REPLACE INTO cache (key, value, time) VALUES (?, ?, ?)',
|
||||
(ns + b'-' + key, value, time.time()))
|
||||
|
||||
def _delete(self, ns, key):
|
||||
with self.connection.transaction as con:
|
||||
con.execute('DELETE FROM cache WHERE key = ?', (ns + b'-' + key, ))
|
34
isso/cache/uwsgi.py
vendored
Normal file
34
isso/cache/uwsgi.py
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
try:
|
||||
import uwsgi
|
||||
except ImportError:
|
||||
uwsgi = None
|
||||
|
||||
from . import Base
|
||||
|
||||
|
||||
class uWSGICache(Base):
|
||||
"""Utilize uWSGI caching framework, in-memory and SMP-safe.
|
||||
"""
|
||||
|
||||
serialize = True
|
||||
|
||||
def __init__(self, threshold=-1, timeout=3600):
|
||||
if uwsgi is None:
|
||||
raise RuntimeError("uWSGI not available")
|
||||
|
||||
super(uWSGICache, self).__init__(threshold, timeout)
|
||||
|
||||
def _get(self, ns, key):
|
||||
if not uwsgi.cache_exists(key, ns):
|
||||
raise KeyError
|
||||
return uwsgi.cache_get(key, ns)
|
||||
|
||||
def _delete(self, ns, key):
|
||||
uwsgi.cache_del(key, ns)
|
||||
|
||||
def _set(self, ns, key, value):
|
||||
uwsgi.cache_set(key, value, self.timeout, ns)
|
@ -1,26 +1,28 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
try:
|
||||
text_type = unicode # Python 2
|
||||
string_types = (str, unicode)
|
||||
PY2K = True
|
||||
except NameError: # Python 3
|
||||
PY2K = False
|
||||
import sys
|
||||
PY2K = sys.version_info[0] == 2
|
||||
|
||||
if not PY2K:
|
||||
|
||||
map, zip, filter = map, zip, filter
|
||||
from functools import reduce
|
||||
|
||||
iteritems = lambda dikt: iter(dikt.items())
|
||||
|
||||
text_type = str
|
||||
string_types = (str, )
|
||||
|
||||
if not PY2K:
|
||||
buffer = memoryview
|
||||
filter, map, zip = filter, map, zip
|
||||
|
||||
def iteritems(dikt):
|
||||
return iter(dikt.items()) # noqa: E731
|
||||
from functools import reduce
|
||||
else:
|
||||
buffer = buffer
|
||||
from itertools import ifilter, imap, izip
|
||||
filter, map, zip = ifilter, imap, izip
|
||||
|
||||
def iteritems(dikt):
|
||||
return dikt.iteritems() # noqa: E731
|
||||
from itertools import imap, izip, ifilter
|
||||
map, zip, filter = imap, izip, ifilter
|
||||
reduce = reduce
|
||||
|
||||
iteritems = lambda dikt: dikt.iteritems()
|
||||
|
||||
text_type = unicode
|
||||
string_types = (str, unicode)
|
||||
|
||||
buffer = buffer
|
||||
|
@ -7,10 +7,7 @@ import logging
|
||||
import datetime
|
||||
|
||||
from email.utils import parseaddr, formataddr
|
||||
try:
|
||||
from backports.configparser import ConfigParser
|
||||
except ImportError:
|
||||
from configparser import ConfigParser
|
||||
from configparser import ConfigParser
|
||||
|
||||
from isso.compat import text_type as str
|
||||
|
||||
@ -38,7 +35,7 @@ def timedelta(string):
|
||||
"""
|
||||
|
||||
keys = ["weeks", "days", "hours", "minutes", "seconds"]
|
||||
regex = "".join(["((?P<%s>\\d+)%s ?)?" % (k, k[0]) for k in keys])
|
||||
regex = "".join(["((?P<%s>\d+)%s ?)?" % (k, k[0]) for k in keys])
|
||||
kwargs = {}
|
||||
for k, v in re.match(regex, string).groupdict(default="0").items():
|
||||
kwargs[k] = int(v)
|
||||
@ -93,10 +90,7 @@ class IssoParser(ConfigParser):
|
||||
except ValueError:
|
||||
return super(IssoParser, self).getint(section, key)
|
||||
else:
|
||||
try:
|
||||
return int(delta.total_seconds())
|
||||
except AttributeError:
|
||||
return int(delta.total_seconds())
|
||||
return int(delta.total_seconds())
|
||||
|
||||
def getlist(self, section, key):
|
||||
return list(map(str.strip, self.get(section, key).split(',')))
|
||||
@ -123,9 +117,8 @@ def new(options=None):
|
||||
def load(default, user=None):
|
||||
|
||||
# return set of (section, option)
|
||||
def setify(cp):
|
||||
return set((section, option) for section in cp.sections()
|
||||
for option in cp.options(section))
|
||||
setify = lambda cp: set((section, option) for section in cp.sections()
|
||||
for option in cp.options(section))
|
||||
|
||||
parser = new()
|
||||
parser.read(default)
|
||||
|
0
isso/controllers/__init__.py
Normal file
0
isso/controllers/__init__.py
Normal file
289
isso/controllers/comments.py
Normal file
289
isso/controllers/comments.py
Normal file
@ -0,0 +1,289 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
from sqlalchemy.sql import func, select, not_
|
||||
|
||||
from isso.spam import Guard
|
||||
from isso.utils import Bloomfilter
|
||||
from isso.models import Comment
|
||||
|
||||
from isso.compat import string_types, buffer
|
||||
|
||||
|
||||
class Invalid(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Denied(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Validator(object):
|
||||
|
||||
# from Django appearently, looks good to me *duck*
|
||||
__url_re = re.compile(
|
||||
r'^'
|
||||
r'(https?://)?'
|
||||
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain...
|
||||
r'localhost|' # localhost...
|
||||
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
|
||||
r'(?::\d+)?' # optional port
|
||||
r'(?:/?|[/?]\S+)'
|
||||
r'$', re.IGNORECASE)
|
||||
|
||||
@classmethod
|
||||
def isurl(cls, text):
|
||||
return Validator.__url_re.match(text) is not None
|
||||
|
||||
@classmethod
|
||||
def verify(cls, comment):
|
||||
|
||||
if not isinstance(comment["parent"], (int, type(None))):
|
||||
return False, "parent must be an integer or null"
|
||||
|
||||
if not isinstance(comment["text"], string_types):
|
||||
return False, "text must be a string"
|
||||
|
||||
if len(comment["text"].rstrip()) < 3:
|
||||
return False, "text is too short (minimum length: 3)"
|
||||
|
||||
for key in ("author", "email", "website"):
|
||||
if not isinstance(comment[key], (string_types, type(None))):
|
||||
return False, "%s must be a string or null" % key
|
||||
|
||||
if len(comment["email"] or "") > 254:
|
||||
return False, "http://tools.ietf.org/html/rfc5321#section-4.5.3"
|
||||
|
||||
if comment["website"]:
|
||||
if len(comment["website"]) > 254:
|
||||
return False, "arbitrary length limit"
|
||||
if not Validator.isurl(comment["website"]):
|
||||
return False, "Website not Django-conform"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
class Controller(object):
|
||||
|
||||
def __init__(self, db, guard=None):
|
||||
if guard is None:
|
||||
guard = Guard(db, enabled=False)
|
||||
|
||||
self.db = db
|
||||
self.guard = guard
|
||||
|
||||
@classmethod
|
||||
def Comment(cls, rv):
|
||||
return Comment(
|
||||
id=rv[0], parent=rv[1], thread=rv[2], created=rv[3], modified=rv[4],
|
||||
mode=rv[5], remote_addr=rv[6], text=rv[7], author=rv[8], email=rv[9],
|
||||
website=rv[10], likes=rv[11], dislikes=rv[12], voters=Bloomfilter(bytearray(rv[13])))
|
||||
|
||||
def new(self, remote_addr, thread, obj, moderated=False):
|
||||
obj.setdefault("text", "")
|
||||
obj.setdefault("parent", None)
|
||||
for field in ("email", "author", "website"):
|
||||
obj.setdefault(field, None)
|
||||
|
||||
valid, reason = Validator.verify(obj)
|
||||
if not valid:
|
||||
raise Invalid(reason)
|
||||
|
||||
if self.guard.enabled:
|
||||
valid, reason = self.guard.validate(remote_addr, thread, obj)
|
||||
if not valid:
|
||||
raise Denied(reason)
|
||||
|
||||
obj["id"] = None
|
||||
obj["thread"] = thread.id
|
||||
obj["mode"] = 2 if moderated else 1
|
||||
obj["created"] = time.time()
|
||||
obj["modified"] = None
|
||||
obj["remote_addr"] = remote_addr
|
||||
|
||||
obj["likes"] = obj["dislikes"] = 0
|
||||
obj["voters"] = Bloomfilter(iterable=[remote_addr])
|
||||
|
||||
if obj["parent"] is not None:
|
||||
parent = self.get(obj["parent"])
|
||||
if parent is None:
|
||||
obj["parent"] = None
|
||||
elif parent.parent: # normalize to max depth of 1
|
||||
obj["parent"] = parent.parent
|
||||
|
||||
obj = Comment(**obj)
|
||||
_id = self.db.engine.execute(self.db.comments.insert()
|
||||
.values((obj.id, obj.parent, obj.thread, obj.created, obj.modified,
|
||||
obj.mode, obj.remote_addr, obj.text, obj.author, obj.email,
|
||||
obj.website, obj.likes, obj.dislikes, buffer(obj.voters.array)))
|
||||
).inserted_primary_key[0]
|
||||
|
||||
return obj.new(id=_id)
|
||||
|
||||
def edit(self, _id, new):
|
||||
obj = self.get(_id)
|
||||
if not obj:
|
||||
return None
|
||||
|
||||
new.setdefault("text", "")
|
||||
new.setdefault("parent", None)
|
||||
for field in ("email", "author", "website"):
|
||||
new.setdefault(field, None)
|
||||
|
||||
valid, reason = Validator.verify(new)
|
||||
if not valid:
|
||||
raise Invalid(reason)
|
||||
|
||||
obj = obj.new(text=new["text"], author=new["author"], email=new["email"],
|
||||
website=new["website"], modified=time.time())
|
||||
self.db.engine.execute(self.db.comments.update()
|
||||
.values(text=obj.text, author=obj.author, email=obj.email,
|
||||
website=obj.website, modified=obj.modified)
|
||||
.where(self.db.comments.c.id == _id))
|
||||
|
||||
return obj
|
||||
|
||||
def get(self, _id):
|
||||
"""Retrieve comment with :param id: if any.
|
||||
"""
|
||||
rv = self.db.engine.execute(
|
||||
self.db.comments.select(self.db.comments.c.id == _id)).fetchone()
|
||||
|
||||
if not rv:
|
||||
return None
|
||||
|
||||
return Controller.Comment(rv)
|
||||
|
||||
def all(self, thread, mode=1, after=0, parent='any', order_by='id', limit=None):
|
||||
stmt = (self.db.comments.select()
|
||||
.where(self.db.comments.c.thread == thread.id)
|
||||
.where(self.db.comments.c.mode.op("|")(mode) == self.db.comments.c.mode)
|
||||
.where(self.db.comments.c.created > after))
|
||||
|
||||
if parent != 'any':
|
||||
stmt = stmt.where(self.db.comments.c.parent == parent)
|
||||
|
||||
stmt.order_by(getattr(self.db.comments.c, order_by))
|
||||
|
||||
if limit:
|
||||
stmt.limit(limit)
|
||||
|
||||
return list(map(Controller.Comment, self.db.engine.execute(stmt).fetchall()))
|
||||
|
||||
def vote(self, remote_addr, _id, like):
|
||||
"""Vote with +1 or -1 on comment :param id:
|
||||
|
||||
Returns True on success (in either direction), False if :param
|
||||
remote_addr: has already voted. A comment can not be voted by its
|
||||
original author.
|
||||
"""
|
||||
obj = self.get(_id)
|
||||
if obj is None:
|
||||
return False
|
||||
|
||||
if remote_addr in obj.voters:
|
||||
return False
|
||||
|
||||
if like:
|
||||
obj = obj.new(likes=obj.likes + 1)
|
||||
else:
|
||||
obj = obj.new(dislikes=obj.dislikes + 1)
|
||||
|
||||
obj.voters.add(remote_addr)
|
||||
self.db.engine.execute(self.db.comments.update()
|
||||
.values(likes=obj.likes, dislikes=obj.dislikes,
|
||||
voters=buffer(obj.voters.array))
|
||||
.where(self.db.comments.c.id == _id))
|
||||
|
||||
return True
|
||||
|
||||
def like(self, remote_addr, _id):
|
||||
return self.vote(remote_addr, _id, like=True)
|
||||
|
||||
def dislike(self, remote_addr, _id):
|
||||
return self.vote(remote_addr, _id, like=False)
|
||||
|
||||
def delete(self, _id):
|
||||
"""
|
||||
Delete comment with :param id:
|
||||
|
||||
If the comment is referenced by another (not yet deleted) comment, the
|
||||
comment is *not* removed, but cleared and flagged as deleted.
|
||||
"""
|
||||
refs = self.db.engine.execute(
|
||||
self.db.comments.select(self.db.comments.c.id).where(
|
||||
self.db.comments.c.parent == _id)).fetchone()
|
||||
|
||||
if refs is None:
|
||||
self.db.engine.execute(
|
||||
self.db.comments.delete(self.db.comments.c.id == _id))
|
||||
self.db.engine.execute(
|
||||
self.db.comments.delete()
|
||||
.where(self.db.comments.c.mode.op("|")(4) == self.db.comments.c.mode)
|
||||
.where(not_(self.db.comments.c.id.in_(
|
||||
select([self.db.comments.c.parent]).where(
|
||||
self.db.comments.c.parent != None)))))
|
||||
return None
|
||||
|
||||
obj = self.get(_id)
|
||||
obj = obj.new(text="", author=None, email=None, website=None, mode=4)
|
||||
self.db.engine.execute(self.db.comments.update()
|
||||
.values(text=obj.text, author=obj.email, website=obj.website, mode=obj.mode)
|
||||
.where(self.db.comments.c.id == _id))
|
||||
|
||||
return obj
|
||||
|
||||
def count(self, *threads):
|
||||
"""Retrieve comment count for :param threads:
|
||||
"""
|
||||
|
||||
ids = [getattr(th, "id", None) for th in threads]
|
||||
|
||||
threads = dict(
|
||||
self.db.engine.execute(
|
||||
select([self.db.comments.c.thread, func.count(self.db.comments)])
|
||||
.where(self.db.comments.c.thread.in_(ids))
|
||||
.group_by(self.db.comments.c.thread)).fetchall())
|
||||
|
||||
return [threads.get(_id, 0) for _id in ids]
|
||||
|
||||
def reply_count(self, thread, mode=5, after=0):
|
||||
|
||||
rv = self.db.engine.execute(
|
||||
select([self.db.comments.c.parent, func.count(self.db.comments)])
|
||||
.where(self.db.comments.c.thread == thread.id)
|
||||
# .where(self.db.comments.c.mode.op("|")(mode) == mode)
|
||||
.where(self.db.comments.c.created)
|
||||
.group_by(self.db.comments.c.parent)).fetchall()
|
||||
|
||||
return dict(rv)
|
||||
|
||||
def activate(self, _id):
|
||||
"""Activate comment :param id: and return True on success.
|
||||
"""
|
||||
obj = self.get(_id)
|
||||
if obj is None:
|
||||
return False
|
||||
|
||||
i = self.db.engine.execute(self.db.comments.update()
|
||||
.values(mode=1)
|
||||
.where(self.db.comments.c.id == _id)
|
||||
.where(self.db.comments.c.mode == 2)).rowcount
|
||||
|
||||
return i > 0
|
||||
|
||||
def prune(self, delta):
|
||||
"""Remove comments still in moderation queue older than max-age.
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
self.db.engine.execute(
|
||||
self.db.comments.delete()
|
||||
.where(self.db.comments.c.mode == 2)
|
||||
.where(now - self.db.comments.c.created > delta))
|
||||
|
||||
return
|
36
isso/controllers/threads.py
Normal file
36
isso/controllers/threads.py
Normal file
@ -0,0 +1,36 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from isso.models import Thread
|
||||
|
||||
|
||||
class Controller(object):
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def new(self, uri, title=None):
|
||||
_id = self.db.engine.execute(
|
||||
self.db.threads.insert().values(uri=uri, title=title)
|
||||
).inserted_primary_key[0]
|
||||
|
||||
return Thread(_id, uri, title)
|
||||
|
||||
def get(self, uri):
|
||||
rv = self.db.engine.execute(
|
||||
self.db.threads.select(self.db.threads.c.uri == uri)).fetchone()
|
||||
|
||||
if not rv:
|
||||
return None
|
||||
|
||||
return Thread(*rv)
|
||||
|
||||
def delete(self, uri):
|
||||
thread = self.get(uri)
|
||||
|
||||
self.db.engine.execute(
|
||||
self.db.comments.delete().where(self.db.comments.c.thread == thread.id))
|
||||
|
||||
self.db.engine.execute(
|
||||
self.db.threads.delete().where(self.db.threads.c.id == thread.id))
|
133
isso/core.py
133
isso/core.py
@ -1,133 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import multiprocessing
|
||||
|
||||
try:
|
||||
import uwsgi
|
||||
except ImportError:
|
||||
uwsgi = None
|
||||
|
||||
from isso.compat import PY2K
|
||||
|
||||
if PY2K:
|
||||
import thread
|
||||
else:
|
||||
import _thread as thread
|
||||
|
||||
from flask_caching.backends.null import NullCache
|
||||
from flask_caching.backends.simple import SimpleCache
|
||||
|
||||
logger = logging.getLogger("isso")
|
||||
|
||||
|
||||
class Cache:
|
||||
"""Wrapper around werkzeug's cache class, to make it compatible to
|
||||
uWSGI's cache framework.
|
||||
"""
|
||||
|
||||
def __init__(self, cache):
|
||||
self.cache = cache
|
||||
|
||||
def get(self, cache, key):
|
||||
return self.cache.get(key)
|
||||
|
||||
def set(self, cache, key, value):
|
||||
return self.cache.set(key, value)
|
||||
|
||||
def delete(self, cache, key):
|
||||
return self.cache.delete(key)
|
||||
|
||||
|
||||
class Mixin(object):
|
||||
|
||||
def __init__(self, conf):
|
||||
self.lock = threading.Lock()
|
||||
self.cache = Cache(NullCache())
|
||||
|
||||
def notify(self, subject, body, retries=5):
|
||||
pass
|
||||
|
||||
|
||||
def threaded(func):
|
||||
"""
|
||||
Decorator to execute each :param func: call in a separate thread.
|
||||
"""
|
||||
|
||||
def dec(self, *args, **kwargs):
|
||||
thread.start_new_thread(func, (self, ) + args, kwargs)
|
||||
|
||||
return dec
|
||||
|
||||
|
||||
class ThreadedMixin(Mixin):
|
||||
|
||||
def __init__(self, conf):
|
||||
|
||||
super(ThreadedMixin, self).__init__(conf)
|
||||
|
||||
if conf.getboolean("moderation", "enabled"):
|
||||
self.purge(conf.getint("moderation", "purge-after"))
|
||||
|
||||
self.cache = Cache(SimpleCache(threshold=1024, default_timeout=3600))
|
||||
|
||||
@threaded
|
||||
def purge(self, delta):
|
||||
while True:
|
||||
with self.lock:
|
||||
self.db.comments.purge(delta)
|
||||
time.sleep(delta)
|
||||
|
||||
|
||||
class ProcessMixin(ThreadedMixin):
|
||||
|
||||
def __init__(self, conf):
|
||||
|
||||
super(ProcessMixin, self).__init__(conf)
|
||||
self.lock = multiprocessing.Lock()
|
||||
|
||||
|
||||
class uWSGICache(object):
|
||||
"""Uses uWSGI Caching Framework. INI configuration:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
cache2 = name=hash,items=1024,blocksize=32
|
||||
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get(self, cache, key):
|
||||
return uwsgi.cache_get(key, cache)
|
||||
|
||||
@classmethod
|
||||
def set(self, cache, key, value):
|
||||
uwsgi.cache_set(key, value, 3600, cache)
|
||||
|
||||
@classmethod
|
||||
def delete(self, cache, key):
|
||||
uwsgi.cache_del(key, cache)
|
||||
|
||||
|
||||
class uWSGIMixin(Mixin):
|
||||
|
||||
def __init__(self, conf):
|
||||
|
||||
super(uWSGIMixin, self).__init__(conf)
|
||||
|
||||
self.lock = multiprocessing.Lock()
|
||||
self.cache = uWSGICache
|
||||
|
||||
timedelta = conf.getint("moderation", "purge-after")
|
||||
|
||||
def purge(signum):
|
||||
return self.db.comments.purge(timedelta)
|
||||
uwsgi.register_signal(1, "", purge)
|
||||
uwsgi.add_timer(1, timedelta)
|
||||
|
||||
# run purge once
|
||||
purge(1)
|
@ -1,134 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
}
|
||||
input {
|
||||
text-align: center;
|
||||
}
|
||||
.header::before, .header::after {
|
||||
content: " ";
|
||||
display: table;
|
||||
}
|
||||
.header::after {
|
||||
clear: both;
|
||||
}
|
||||
.header::before, .header::after {
|
||||
content: " ";
|
||||
display: table;
|
||||
}
|
||||
.header {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 68em;
|
||||
padding-bottom: 1em;
|
||||
padding-top: 1em;
|
||||
}
|
||||
.header header {
|
||||
display: block;
|
||||
float: left;
|
||||
font-weight: normal;
|
||||
margin-right: 16.0363%;
|
||||
width: 41.9818%;
|
||||
}
|
||||
.header header .logo {
|
||||
float: left;
|
||||
max-height: 60px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.header header h1 {
|
||||
font-size: 1.55em;
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
.header header h2 {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.header a, .header a:visited {
|
||||
color: #4d4c4c;
|
||||
text-decoration: none;
|
||||
}
|
||||
.outer {
|
||||
background-color: #eeeeee;
|
||||
box-shadow: 0 0 0.5em #c0c0c0 inset;
|
||||
}
|
||||
.outer .filters::before, .outer .filters::after {
|
||||
content: " ";
|
||||
display: table;
|
||||
}
|
||||
.outer .filters::after {
|
||||
clear: both;
|
||||
}
|
||||
.outer .filters::before, .outer .filters::after {
|
||||
content: " ";
|
||||
display: table;
|
||||
}
|
||||
.outer .filters {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 68em;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #4d4c4c;
|
||||
}
|
||||
.label {
|
||||
background-color: #ddd;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
line-height: 1.4em;
|
||||
outline: 0 none;
|
||||
padding: calc(0.6em - 1px);
|
||||
}
|
||||
.active {
|
||||
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6) inset;
|
||||
}
|
||||
.label-valid {
|
||||
background-color: #cfc;
|
||||
border-color: #cfc;
|
||||
}
|
||||
.label-pending {
|
||||
background-color: #ffc;
|
||||
border-color: #ffc;
|
||||
}
|
||||
.mode {
|
||||
float: left;
|
||||
}
|
||||
.pagination {
|
||||
float: right;
|
||||
}
|
||||
.note .label {
|
||||
margin: 9px;
|
||||
padding: 3px;
|
||||
}
|
||||
#login {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.isso-comment-footer a {
|
||||
cursor: pointer;
|
||||
}
|
||||
.thread-title {
|
||||
margin-left: 3em;
|
||||
}
|
||||
.group {
|
||||
float: left;
|
||||
margin-left: 2em;
|
||||
}
|
||||
.editable {
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 5px;
|
||||
margin: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#isso-thread .isso-comment-header a {
|
||||
#isso-thread a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@ -15,42 +15,39 @@
|
||||
color: #555;
|
||||
font-weight: bold;
|
||||
}
|
||||
#isso-thread > .isso-feedlink {
|
||||
float: right;
|
||||
padding-left: 1em;
|
||||
}
|
||||
#isso-thread > .isso-feedlink > a {
|
||||
font-size: 0.8em;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
#isso-thread .textarea {
|
||||
min-height: 58px;
|
||||
outline: 0;
|
||||
}
|
||||
#isso-thread .textarea.placeholder {
|
||||
color: #757575;
|
||||
color: #AAA;
|
||||
}
|
||||
|
||||
#isso-root .isso-comment {
|
||||
.isso-comment {
|
||||
max-width: 68em;
|
||||
padding-top: 0.95em;
|
||||
margin: 0.95em auto;
|
||||
}
|
||||
#isso-root .preview .isso-comment {
|
||||
padding-top: 0;
|
||||
margin: 0;
|
||||
}
|
||||
#isso-root .isso-comment:not(:first-of-type),
|
||||
.isso-comment:not(:first-of-type),
|
||||
.isso-follow-up .isso-comment {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.isso-comment > div.avatar {
|
||||
.isso-comment > div.avatar,
|
||||
.isso-postbox > .avatar {
|
||||
display: block;
|
||||
float: left;
|
||||
width: 7%;
|
||||
margin: 3px 15px 0 0;
|
||||
}
|
||||
.isso-comment > div.avatar > svg {
|
||||
.isso-postbox > .avatar {
|
||||
float: left;
|
||||
margin: 5px 10px 0 5px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.isso-comment > div.avatar > svg,
|
||||
.isso-postbox > .avatar > svg {
|
||||
max-width: 48px;
|
||||
max-height: 48px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
@ -93,8 +90,7 @@
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
}
|
||||
.isso-comment > div.text-wrapper > .textarea-wrapper .textarea,
|
||||
.isso-comment > div.text-wrapper > .textarea-wrapper .preview {
|
||||
.isso-comment > div.text-wrapper > .textarea-wrapper .textarea {
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
.isso-comment > div.text-wrapper > div.text p {
|
||||
@ -112,8 +108,7 @@
|
||||
font-size: 130%;
|
||||
font-weight: bold;
|
||||
}
|
||||
.isso-comment > div.text-wrapper > div.textarea-wrapper .textarea,
|
||||
.isso-comment > div.text-wrapper > div.textarea-wrapper .preview {
|
||||
.isso-comment > div.text-wrapper > div.textarea-wrapper .textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 2px;
|
||||
@ -124,21 +119,18 @@
|
||||
color: gray !important;
|
||||
clear: left;
|
||||
}
|
||||
.isso-feedlink,
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer a {
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
.isso-feedlink:hover,
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer a:hover {
|
||||
color: #111111 !important;
|
||||
text-shadow: #aaaaaa 0 0 1px !important;
|
||||
}
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer > a {
|
||||
position: relative;
|
||||
top: .2em;
|
||||
}
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer > a + a {
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer a.reply,
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer a.edit,
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer a.cancel,
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer a.delete {
|
||||
padding-left: 1em;
|
||||
}
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer .votes {
|
||||
@ -152,14 +144,10 @@
|
||||
.isso-comment .isso-postbox {
|
||||
margin-top: 0.8em;
|
||||
}
|
||||
.isso-comment.isso-no-votes > * > .isso-comment-footer span.votes {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isso-postbox {
|
||||
max-width: 68em;
|
||||
margin: 0 auto 2em;
|
||||
clear: right;
|
||||
}
|
||||
.isso-postbox > .form-wrapper {
|
||||
display: block;
|
||||
@ -169,8 +157,7 @@
|
||||
.isso-postbox > .form-wrapper > .auth-section .post-action {
|
||||
display: block;
|
||||
}
|
||||
.isso-postbox > .form-wrapper .textarea,
|
||||
.isso-postbox > .form-wrapper .preview {
|
||||
.isso-postbox > .form-wrapper .textarea {
|
||||
margin: 0 0 .3em;
|
||||
padding: .4em .8em;
|
||||
border-radius: 3px;
|
||||
@ -178,16 +165,6 @@
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.isso-postbox > .form-wrapper input[type=checkbox] {
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
bottom: 1px;
|
||||
margin-left: 0;
|
||||
}
|
||||
.isso-postbox > .form-wrapper .notification-section {
|
||||
font-size: 0.90em;
|
||||
padding-top: .3em;
|
||||
}
|
||||
#isso-thread .textarea:focus,
|
||||
#isso-thread input:focus {
|
||||
border-color: rgba(0, 0, 0, 0.8);
|
||||
@ -210,7 +187,7 @@
|
||||
.isso-postbox > .form-wrapper > .auth-section .post-action {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
margin: 0 0 0 5px;
|
||||
margin: 0;
|
||||
}
|
||||
.isso-postbox > .form-wrapper > .auth-section .post-action > input {
|
||||
padding: calc(.3em - 1px);
|
||||
@ -228,32 +205,6 @@
|
||||
.isso-postbox > .form-wrapper > .auth-section .post-action > input:active {
|
||||
background-color: #BBB;
|
||||
}
|
||||
.isso-postbox > .form-wrapper .preview,
|
||||
.isso-postbox > .form-wrapper input[name="edit"],
|
||||
.isso-postbox.preview-mode > .form-wrapper input[name="preview"],
|
||||
.isso-postbox.preview-mode > .form-wrapper .textarea {
|
||||
display: none;
|
||||
}
|
||||
.isso-postbox.preview-mode > .form-wrapper .preview {
|
||||
display: block;
|
||||
}
|
||||
.isso-postbox.preview-mode > .form-wrapper input[name="edit"] {
|
||||
display: inline;
|
||||
}
|
||||
.isso-postbox > .form-wrapper .preview {
|
||||
background-color: #f8f8f8;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#f8f8f8,
|
||||
#f8f8f8 10px,
|
||||
#fff 10px,
|
||||
#fff 20px
|
||||
);
|
||||
}
|
||||
.isso-postbox > .form-wrapper > .notification-section {
|
||||
display: none;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
@media screen and (max-width:600px) {
|
||||
.isso-postbox > .form-wrapper > .auth-section .input-wrapper {
|
||||
display: block;
|
||||
@ -263,4 +214,9 @@
|
||||
.isso-postbox > .form-wrapper > .auth-section .input-wrapper input {
|
||||
width: 100%;
|
||||
}
|
||||
.isso-postbox > .form-wrapper > .auth-section .post-action {
|
||||
display: block;
|
||||
float: none;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +1,164 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import sqlite3
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import binascii
|
||||
import operator
|
||||
import threading
|
||||
|
||||
import os.path
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import Table, Column, MetaData, create_engine
|
||||
from sqlalchemy import ForeignKey, Integer, Float, String, LargeBinary
|
||||
from sqlalchemy.sql import select
|
||||
|
||||
logger = logging.getLogger("isso")
|
||||
|
||||
from isso.compat import buffer
|
||||
|
||||
from isso.db.comments import Comments
|
||||
from isso.db.threads import Threads
|
||||
from isso.db.spam import Guard
|
||||
from isso.db.preferences import Preferences
|
||||
class Adapter(object):
|
||||
|
||||
def __init__(self, db):
|
||||
self.engine = create_engine(db, echo=False)
|
||||
self.metadata = MetaData()
|
||||
|
||||
self.comments = Table("comments", self.metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("parent", Integer),
|
||||
Column("thread", None, ForeignKey("threads.id")),
|
||||
Column("created", Float),
|
||||
Column("modified", Float),
|
||||
Column("mode", Integer),
|
||||
Column("remote_addr", String(48)), # XXX use a BigInt
|
||||
Column("text", String(65535)),
|
||||
Column("author", String(255)),
|
||||
Column("email", String(255)),
|
||||
Column("website", String(255)),
|
||||
Column("likes", Integer),
|
||||
Column("dislikes", Integer),
|
||||
Column("voters", LargeBinary(256)))
|
||||
|
||||
self.threads = Table("threads", self.metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("uri", String(255), unique=True),
|
||||
Column("title", String(255)))
|
||||
|
||||
preferences = Table("preferences", self.metadata,
|
||||
Column("key", String(255), primary_key=True),
|
||||
Column("value", String(255)))
|
||||
|
||||
self.metadata.create_all(self.engine)
|
||||
self.preferences = Preferences(self.engine, preferences)
|
||||
|
||||
@property
|
||||
def transaction(self):
|
||||
return self.engine.begin()
|
||||
|
||||
|
||||
class SQLite3:
|
||||
class Preferences(object):
|
||||
"""A simple key-value store using SQL.
|
||||
"""
|
||||
|
||||
defaults = [
|
||||
("session-key", binascii.b2a_hex(os.urandom(24))),
|
||||
]
|
||||
|
||||
def __init__(self, engine, preferences):
|
||||
self.engine = engine
|
||||
self.preferences = preferences
|
||||
|
||||
for (key, value) in Preferences.defaults:
|
||||
if self.get(key) is None:
|
||||
self.set(key, value)
|
||||
|
||||
def get(self, key, default=None):
|
||||
rv = self.engine.execute(
|
||||
select([self.preferences.c.value])
|
||||
.where(self.preferences.c.key == key)).fetchone()
|
||||
|
||||
if rv is None:
|
||||
return default
|
||||
|
||||
return rv[0]
|
||||
|
||||
def set(self, key, value):
|
||||
self.engine.execute(
|
||||
self.preferences.insert().values(
|
||||
key=key, value=value))
|
||||
|
||||
|
||||
class Transaction(object):
|
||||
"""A context manager to lock the database across processes and automatic
|
||||
rollback on failure. On success, reset the isolation level back to normal.
|
||||
|
||||
SQLite3's DEFERRED (default) transaction mode causes database corruption
|
||||
for concurrent writes to the database from multiple processes. IMMEDIATE
|
||||
ensures a global write lock, but reading is still possible.
|
||||
"""
|
||||
|
||||
def __init__(self, con):
|
||||
self.con = con
|
||||
|
||||
def __enter__(self):
|
||||
self._orig = self.con.isolation_level
|
||||
self.con.isolation_level = "IMMEDIATE"
|
||||
self.con.execute("BEGIN IMMEDIATE")
|
||||
return self.con
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
try:
|
||||
if exc_type:
|
||||
self.con.rollback()
|
||||
else:
|
||||
self.con.commit()
|
||||
finally:
|
||||
self.con.isolation_level = self._orig
|
||||
|
||||
|
||||
class SQLite3(object):
|
||||
"""SQLite3 connection pool across multiple threads. Implementation idea
|
||||
from `Peewee <https://github.com/coleifer/peewee>`_.
|
||||
"""
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = os.path.expanduser(db)
|
||||
self.lock = threading.Lock()
|
||||
self.local = threading.local()
|
||||
|
||||
def connect(self):
|
||||
with self.lock:
|
||||
self.local.conn = sqlite3.connect(self.db, isolation_level=None)
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
self.local.conn.close()
|
||||
self.local.conn = None
|
||||
|
||||
def execute(self, sql, args=()):
|
||||
if isinstance(sql, (list, tuple)):
|
||||
sql = ' '.join(sql)
|
||||
|
||||
return self.connection.execute(sql, args)
|
||||
|
||||
@property
|
||||
def connection(self):
|
||||
if not hasattr(self.local, 'conn') or self.local.conn is None:
|
||||
self.connect()
|
||||
return self.local.conn
|
||||
|
||||
@property
|
||||
def transaction(self):
|
||||
return Transaction(self.connection)
|
||||
|
||||
@property
|
||||
def total_changes(self):
|
||||
return self.connection.total_changes
|
||||
|
||||
|
||||
class Foo(object):
|
||||
"""DB-dependend wrapper around SQLite3.
|
||||
|
||||
Runs migration if `user_version` is older than `MAX_VERSION` and register
|
||||
@ -26,9 +167,8 @@ class SQLite3:
|
||||
|
||||
MAX_VERSION = 3
|
||||
|
||||
def __init__(self, path, conf):
|
||||
|
||||
self.path = os.path.expanduser(path)
|
||||
def __init__(self, conn, conf):
|
||||
self.connection = conn
|
||||
self.conf = conf
|
||||
|
||||
rv = self.execute([
|
||||
@ -42,9 +182,9 @@ class SQLite3:
|
||||
self.guard = Guard(self)
|
||||
|
||||
if rv is None:
|
||||
self.execute("PRAGMA user_version = %i" % SQLite3.MAX_VERSION)
|
||||
self.execute("PRAGMA user_version = %i" % Adapter.MAX_VERSION)
|
||||
else:
|
||||
self.migrate(to=SQLite3.MAX_VERSION)
|
||||
self.migrate(to=Adapter.MAX_VERSION)
|
||||
|
||||
self.execute([
|
||||
'CREATE TRIGGER IF NOT EXISTS remove_stale_threads',
|
||||
@ -53,14 +193,6 @@ class SQLite3:
|
||||
' DELETE FROM threads WHERE id NOT IN (SELECT tid FROM comments);',
|
||||
'END'])
|
||||
|
||||
def execute(self, sql, args=()):
|
||||
|
||||
if isinstance(sql, (list, tuple)):
|
||||
sql = ' '.join(sql)
|
||||
|
||||
with sqlite3.connect(self.path) as con:
|
||||
return con.execute(sql, args)
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self.execute("PRAGMA user_version").fetchone()[0]
|
||||
@ -79,7 +211,7 @@ class SQLite3:
|
||||
from isso.utils import Bloomfilter
|
||||
bf = buffer(Bloomfilter(iterable=["127.0.0.0"]).array)
|
||||
|
||||
with sqlite3.connect(self.path) as con:
|
||||
with self.connection.transaction as con:
|
||||
con.execute('UPDATE comments SET voters=?', (bf, ))
|
||||
con.execute('PRAGMA user_version = 1')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
@ -87,7 +219,7 @@ class SQLite3:
|
||||
# move [general] session-key to database
|
||||
if self.version == 1:
|
||||
|
||||
with sqlite3.connect(self.path) as con:
|
||||
with self.connection.transaction as con:
|
||||
if self.conf.has_option("general", "session-key"):
|
||||
con.execute('UPDATE preferences SET value=? WHERE key=?', (
|
||||
self.conf.get("general", "session-key"), "session-key"))
|
||||
@ -98,12 +230,10 @@ class SQLite3:
|
||||
# limit max. nesting level to 1
|
||||
if self.version == 2:
|
||||
|
||||
def first(rv):
|
||||
return list(map(operator.itemgetter(0), rv))
|
||||
first = lambda rv: list(map(operator.itemgetter(0), rv))
|
||||
|
||||
with sqlite3.connect(self.path) as con:
|
||||
top = first(con.execute(
|
||||
"SELECT id FROM comments WHERE parent IS NULL").fetchall())
|
||||
with self.connection.transaction as con:
|
||||
top = first(con.execute("SELECT id FROM comments WHERE parent IS NULL").fetchall())
|
||||
flattened = defaultdict(set)
|
||||
|
||||
for id in top:
|
||||
@ -111,15 +241,16 @@ class SQLite3:
|
||||
ids = [id, ]
|
||||
|
||||
while ids:
|
||||
rv = first(con.execute(
|
||||
"SELECT id FROM comments WHERE parent=?", (ids.pop(), )))
|
||||
rv = first(con.execute("SELECT id FROM comments WHERE parent=?", (ids.pop(), )))
|
||||
ids.extend(rv)
|
||||
flattened[id].update(set(rv))
|
||||
|
||||
for id in flattened.keys():
|
||||
for n in flattened[id]:
|
||||
con.execute(
|
||||
"UPDATE comments SET parent=? WHERE id=?", (id, n))
|
||||
con.execute("UPDATE comments SET parent=? WHERE id=?", (id, n))
|
||||
|
||||
con.execute('PRAGMA user_version = 3')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
|
||||
def execute(self, sql, args=()):
|
||||
return self.connection.execute(sql, args)
|
||||
|
@ -1,348 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from isso.utils import Bloomfilter
|
||||
from isso.compat import buffer
|
||||
|
||||
|
||||
logger = logging.getLogger("isso")
|
||||
|
||||
|
||||
MAX_LIKES_AND_DISLIKES = 142
|
||||
|
||||
|
||||
class Comments:
|
||||
"""Hopefully DB-independend SQL to store, modify and retrieve all
|
||||
comment-related actions. Here's a short scheme overview:
|
||||
|
||||
| tid (thread id) | id (comment id) | parent | ... | voters | remote_addr |
|
||||
+-----------------+-----------------+--------+-----+--------+-------------+
|
||||
| 1 | 1 | null | ... | BLOB | 127.0.0.0 |
|
||||
| 1 | 2 | 1 | ... | BLOB | 127.0.0.0 |
|
||||
+-----------------+-----------------+--------+-----+--------+-------------+
|
||||
|
||||
The tuple (tid, id) is unique and thus primary key.
|
||||
"""
|
||||
|
||||
fields = ['tid', 'id', 'parent', 'created', 'modified',
|
||||
'mode', # status of the comment 1 = valid, 2 = pending,
|
||||
# 4 = soft-deleted (cannot hard delete because of replies)
|
||||
'remote_addr', 'text', 'author', 'email', 'website',
|
||||
'likes', 'dislikes', 'voters', 'notification']
|
||||
|
||||
def __init__(self, db):
|
||||
|
||||
self.db = db
|
||||
self.db.execute([
|
||||
'CREATE TABLE IF NOT EXISTS comments (',
|
||||
' tid REFERENCES threads(id), id INTEGER PRIMARY KEY, parent INTEGER,',
|
||||
' created FLOAT NOT NULL, modified FLOAT, mode INTEGER, remote_addr VARCHAR,',
|
||||
' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,',
|
||||
' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL,',
|
||||
' notification INTEGER DEFAULT 0);'])
|
||||
try:
|
||||
self.db.execute(['ALTER TABLE comments ADD COLUMN notification INTEGER DEFAULT 0;'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def add(self, uri, c):
|
||||
"""
|
||||
Add new comment to DB and return a mapping of :attribute:`fields` and
|
||||
database values.
|
||||
"""
|
||||
|
||||
if c.get("parent") is not None:
|
||||
ref = self.get(c["parent"])
|
||||
if ref.get("parent") is not None:
|
||||
c["parent"] = ref["parent"]
|
||||
|
||||
self.db.execute([
|
||||
'INSERT INTO comments (',
|
||||
' tid, parent,'
|
||||
' created, modified, mode, remote_addr,',
|
||||
' text, author, email, website, voters, notification)',
|
||||
'SELECT',
|
||||
' threads.id, ?,',
|
||||
' ?, ?, ?, ?,',
|
||||
' ?, ?, ?, ?, ?, ?',
|
||||
'FROM threads WHERE threads.uri = ?;'], (
|
||||
c.get('parent'),
|
||||
c.get('created') or time.time(), None, c["mode"], c['remote_addr'],
|
||||
c['text'], c.get('author'), c.get('email'), c.get('website'), buffer(
|
||||
Bloomfilter(iterable=[c['remote_addr']]).array), c.get('notification'),
|
||||
uri)
|
||||
)
|
||||
|
||||
return dict(zip(Comments.fields, self.db.execute(
|
||||
'SELECT *, MAX(c.id) FROM comments AS c INNER JOIN threads ON threads.uri = ?',
|
||||
(uri, )).fetchone()))
|
||||
|
||||
def activate(self, id):
|
||||
"""
|
||||
Activate comment id if pending.
|
||||
"""
|
||||
self.db.execute([
|
||||
'UPDATE comments SET',
|
||||
' mode=1',
|
||||
'WHERE id=? AND mode=2'], (id, ))
|
||||
|
||||
def is_previously_approved_author(self, email):
|
||||
"""
|
||||
Search for previously activated comments with this author email.
|
||||
"""
|
||||
|
||||
# if the user has not entered email, email is None, in which case we can't check if they have previous comments
|
||||
if email is not None:
|
||||
# search for any activated comments within the last 6 months by email
|
||||
# this SQL should be one of the fastest ways of doing this check
|
||||
# https://stackoverflow.com/questions/18114458/fastest-way-to-determine-if-record-exists
|
||||
rv = self.db.execute([
|
||||
'SELECT CASE WHEN EXISTS(',
|
||||
' select * from comments where email=? and mode=1 and ',
|
||||
' created > strftime("%s", DATETIME("now", "-6 month"))',
|
||||
') THEN 1 ELSE 0 END;'], (email,)).fetchone()
|
||||
return rv[0] == 1
|
||||
else:
|
||||
return False
|
||||
|
||||
def unsubscribe(self, email, id):
|
||||
"""
|
||||
Turn off email notifications for replies to this comment.
|
||||
"""
|
||||
self.db.execute([
|
||||
'UPDATE comments SET',
|
||||
' notification=0',
|
||||
'WHERE email=? AND (id=? OR parent=?);'], (email, id, id))
|
||||
|
||||
def update(self, id, data):
|
||||
"""
|
||||
Update comment :param:`id` with values from :param:`data` and return
|
||||
updated comment.
|
||||
"""
|
||||
self.db.execute([
|
||||
'UPDATE comments SET',
|
||||
','.join(key + '=' + '?' for key in data),
|
||||
'WHERE id=?;'],
|
||||
list(data.values()) + [id])
|
||||
|
||||
return self.get(id)
|
||||
|
||||
def get(self, id):
|
||||
"""
|
||||
Search for comment :param:`id` and return a mapping of :attr:`fields`
|
||||
and values.
|
||||
"""
|
||||
rv = self.db.execute(
|
||||
'SELECT * FROM comments WHERE id=?', (id, )).fetchone()
|
||||
if rv:
|
||||
return dict(zip(Comments.fields, rv))
|
||||
|
||||
return None
|
||||
|
||||
def count_modes(self):
|
||||
"""
|
||||
Return comment mode counts for admin
|
||||
"""
|
||||
comment_count = self.db.execute(
|
||||
'SELECT mode, COUNT(comments.id) FROM comments '
|
||||
'GROUP BY comments.mode').fetchall()
|
||||
return dict(comment_count)
|
||||
|
||||
def fetchall(self, mode=5, after=0, parent='any', order_by='id',
|
||||
limit=100, page=0, asc=1):
|
||||
"""
|
||||
Return comments for admin with :param:`mode`.
|
||||
"""
|
||||
fields_comments = ['tid', 'id', 'parent', 'created', 'modified',
|
||||
'mode', 'remote_addr', 'text', 'author',
|
||||
'email', 'website', 'likes', 'dislikes']
|
||||
fields_threads = ['uri', 'title']
|
||||
sql_comments_fields = ', '.join(['comments.' + f
|
||||
for f in fields_comments])
|
||||
sql_threads_fields = ', '.join(['threads.' + f
|
||||
for f in fields_threads])
|
||||
sql = ['SELECT ' + sql_comments_fields + ', ' + sql_threads_fields + ' '
|
||||
'FROM comments INNER JOIN threads '
|
||||
'ON comments.tid=threads.id '
|
||||
'WHERE comments.mode = ? ']
|
||||
sql_args = [mode]
|
||||
|
||||
if parent != 'any':
|
||||
if parent is None:
|
||||
sql.append('AND comments.parent IS NULL')
|
||||
else:
|
||||
sql.append('AND comments.parent=?')
|
||||
sql_args.append(parent)
|
||||
|
||||
# custom sanitization
|
||||
if order_by not in ['id', 'created', 'modified', 'likes', 'dislikes', 'tid']:
|
||||
sql.append('ORDER BY ')
|
||||
sql.append("comments.created")
|
||||
if not asc:
|
||||
sql.append(' DESC')
|
||||
else:
|
||||
sql.append('ORDER BY ')
|
||||
sql.append('comments.' + order_by)
|
||||
if not asc:
|
||||
sql.append(' DESC')
|
||||
sql.append(", comments.created")
|
||||
|
||||
if limit:
|
||||
sql.append('LIMIT ?,?')
|
||||
sql_args.append(page * limit)
|
||||
sql_args.append(limit)
|
||||
|
||||
rv = self.db.execute(sql, sql_args).fetchall()
|
||||
for item in rv:
|
||||
yield dict(zip(fields_comments + fields_threads, item))
|
||||
|
||||
def fetch(self, uri, mode=5, after=0, parent='any',
|
||||
order_by='id', asc=1, limit=None):
|
||||
"""
|
||||
Return comments for :param:`uri` with :param:`mode`.
|
||||
"""
|
||||
sql = ['SELECT comments.* FROM comments INNER JOIN threads ON',
|
||||
' threads.uri=? AND comments.tid=threads.id AND (? | comments.mode) = ?',
|
||||
' AND comments.created>?']
|
||||
|
||||
sql_args = [uri, mode, mode, after]
|
||||
|
||||
if parent != 'any':
|
||||
if parent is None:
|
||||
sql.append('AND comments.parent IS NULL')
|
||||
else:
|
||||
sql.append('AND comments.parent=?')
|
||||
sql_args.append(parent)
|
||||
|
||||
# custom sanitization
|
||||
if order_by not in ['id', 'created', 'modified', 'likes', 'dislikes']:
|
||||
order_by = 'id'
|
||||
sql.append('ORDER BY ')
|
||||
sql.append(order_by)
|
||||
if not asc:
|
||||
sql.append(' DESC')
|
||||
|
||||
if limit:
|
||||
sql.append('LIMIT ?')
|
||||
sql_args.append(limit)
|
||||
|
||||
rv = self.db.execute(sql, sql_args).fetchall()
|
||||
for item in rv:
|
||||
yield dict(zip(Comments.fields, item))
|
||||
|
||||
def _remove_stale(self):
|
||||
|
||||
sql = ('DELETE FROM',
|
||||
' comments',
|
||||
'WHERE',
|
||||
' mode=4 AND id NOT IN (',
|
||||
' SELECT',
|
||||
' parent',
|
||||
' FROM',
|
||||
' comments',
|
||||
' WHERE parent IS NOT NULL)')
|
||||
|
||||
while self.db.execute(sql).rowcount:
|
||||
continue
|
||||
|
||||
def delete(self, id):
|
||||
"""
|
||||
Delete a comment. There are two distinctions: a comment is referenced
|
||||
by another valid comment's parent attribute or stand-a-lone. In this
|
||||
case the comment can't be removed without losing depending comments.
|
||||
Hence, delete removes all visible data such as text, author, email,
|
||||
website sets the mode field to 4.
|
||||
|
||||
In the second case this comment can be safely removed without any side
|
||||
effects."""
|
||||
|
||||
refs = self.db.execute(
|
||||
'SELECT * FROM comments WHERE parent=?', (id, )).fetchone()
|
||||
|
||||
if refs is None:
|
||||
self.db.execute('DELETE FROM comments WHERE id=?', (id, ))
|
||||
self._remove_stale()
|
||||
return None
|
||||
|
||||
self.db.execute('UPDATE comments SET text=? WHERE id=?', ('', id))
|
||||
self.db.execute('UPDATE comments SET mode=? WHERE id=?', (4, id))
|
||||
for field in ('author', 'website'):
|
||||
self.db.execute('UPDATE comments SET %s=? WHERE id=?' %
|
||||
field, (None, id))
|
||||
|
||||
self._remove_stale()
|
||||
return self.get(id)
|
||||
|
||||
def vote(self, upvote, id, remote_addr):
|
||||
"""+1 a given comment. Returns the new like count (may not change because
|
||||
the creater can't vote on his/her own comment and multiple votes from the
|
||||
same ip address are ignored as well)."""
|
||||
|
||||
rv = self.db.execute(
|
||||
'SELECT likes, dislikes, voters FROM comments WHERE id=?', (id, )) \
|
||||
.fetchone()
|
||||
|
||||
if rv is None:
|
||||
return None
|
||||
|
||||
operation_name = 'Upvote' if upvote else 'Downvote'
|
||||
likes, dislikes, voters = rv
|
||||
if likes + dislikes >= MAX_LIKES_AND_DISLIKES:
|
||||
message = '{} denied due to a "likes + dislikes" total too high ({} >= {})'.format(operation_name, likes + dislikes, MAX_LIKES_AND_DISLIKES)
|
||||
logger.debug('Comments.vote(id=%s): %s', id, message)
|
||||
return {'likes': likes, 'dislikes': dislikes, 'message': message}
|
||||
|
||||
bf = Bloomfilter(bytearray(voters), likes + dislikes)
|
||||
if remote_addr in bf:
|
||||
message = '{} denied because a vote has already been registered for this remote address: {}'.format(operation_name, remote_addr)
|
||||
logger.debug('Comments.vote(id=%s): %s', id, message)
|
||||
return {'likes': likes, 'dislikes': dislikes, 'message': message}
|
||||
|
||||
bf.add(remote_addr)
|
||||
self.db.execute([
|
||||
'UPDATE comments SET',
|
||||
' likes = likes + 1,' if upvote else 'dislikes = dislikes + 1,',
|
||||
' voters = ?'
|
||||
'WHERE id=?;'], (buffer(bf.array), id))
|
||||
|
||||
if upvote:
|
||||
return {'likes': likes + 1, 'dislikes': dislikes}
|
||||
return {'likes': likes, 'dislikes': dislikes + 1}
|
||||
|
||||
def reply_count(self, url, mode=5, after=0):
|
||||
"""
|
||||
Return comment count for main thread and all reply threads for one url.
|
||||
"""
|
||||
|
||||
sql = ['SELECT comments.parent,count(*)',
|
||||
'FROM comments INNER JOIN threads ON',
|
||||
' threads.uri=? AND comments.tid=threads.id AND',
|
||||
' (? | comments.mode = ?) AND',
|
||||
' comments.created > ?',
|
||||
'GROUP BY comments.parent']
|
||||
|
||||
return dict(self.db.execute(sql, [url, mode, mode, after]).fetchall())
|
||||
|
||||
def count(self, *urls):
|
||||
"""
|
||||
Return comment count for one ore more urls..
|
||||
"""
|
||||
|
||||
threads = dict(self.db.execute([
|
||||
'SELECT threads.uri, COUNT(comments.id) FROM comments',
|
||||
'LEFT OUTER JOIN threads ON threads.id = tid AND comments.mode = 1',
|
||||
'GROUP BY threads.uri'
|
||||
]).fetchall())
|
||||
|
||||
return [threads.get(url, 0) for url in urls]
|
||||
|
||||
def purge(self, delta):
|
||||
"""
|
||||
Remove comments older than :param:`delta`.
|
||||
"""
|
||||
self.db.execute([
|
||||
'DELETE FROM comments WHERE mode = 2 AND ? - created > ?;'
|
||||
], (time.time(), delta))
|
||||
self._remove_stale()
|
@ -1,36 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import binascii
|
||||
|
||||
|
||||
class Preferences:
|
||||
|
||||
defaults = [
|
||||
("session-key", binascii.b2a_hex(os.urandom(24)).decode('utf-8')),
|
||||
]
|
||||
|
||||
def __init__(self, db):
|
||||
|
||||
self.db = db
|
||||
self.db.execute([
|
||||
'CREATE TABLE IF NOT EXISTS preferences (',
|
||||
' key VARCHAR PRIMARY KEY, value VARCHAR',
|
||||
');'])
|
||||
|
||||
for (key, value) in Preferences.defaults:
|
||||
if self.get(key) is None:
|
||||
self.set(key, value)
|
||||
|
||||
def get(self, key, default=None):
|
||||
rv = self.db.execute(
|
||||
'SELECT value FROM preferences WHERE key=?', (key, )).fetchone()
|
||||
|
||||
if rv is None:
|
||||
return default
|
||||
|
||||
return rv[0]
|
||||
|
||||
def set(self, key, value):
|
||||
self.db.execute(
|
||||
'INSERT INTO preferences (key, value) VALUES (?, ?)', (key, value))
|
@ -1,76 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class Guard:
|
||||
|
||||
def __init__(self, db):
|
||||
|
||||
self.db = db
|
||||
self.conf = db.conf.section("guard")
|
||||
self.max_age = db.conf.getint("general", "max-age")
|
||||
|
||||
def validate(self, uri, comment):
|
||||
|
||||
if not self.conf.getboolean("enabled"):
|
||||
return True, ""
|
||||
|
||||
for func in (self._limit, self._spam):
|
||||
valid, reason = func(uri, comment)
|
||||
if not valid:
|
||||
return False, reason
|
||||
return True, ""
|
||||
|
||||
@classmethod
|
||||
def ids(cls, rv):
|
||||
return [str(col[0]) for col in rv]
|
||||
|
||||
def _limit(self, uri, comment):
|
||||
|
||||
# block more than :param:`ratelimit` comments per minute
|
||||
rv = self.db.execute([
|
||||
'SELECT id FROM comments WHERE remote_addr = ? AND ? - created < 60;'
|
||||
], (comment["remote_addr"], time.time())).fetchall()
|
||||
|
||||
if len(rv) >= self.conf.getint("ratelimit"):
|
||||
return False, "{0}: ratelimit exceeded ({1})".format(
|
||||
comment["remote_addr"], ', '.join(Guard.ids(rv)))
|
||||
|
||||
# block more than three comments as direct response to the post
|
||||
if comment["parent"] is None:
|
||||
rv = self.db.execute([
|
||||
'SELECT id FROM comments WHERE',
|
||||
' tid = (SELECT id FROM threads WHERE uri = ?)',
|
||||
'AND remote_addr = ?',
|
||||
'AND parent IS NULL;'
|
||||
], (uri, comment["remote_addr"])).fetchall()
|
||||
|
||||
if len(rv) >= self.conf.getint("direct-reply"):
|
||||
return False, "%i direct responses to %s" % (len(rv), uri)
|
||||
|
||||
# block replies to self unless :param:`reply-to-self` is enabled
|
||||
elif self.conf.getboolean("reply-to-self") is False:
|
||||
rv = self.db.execute([
|
||||
'SELECT id FROM comments WHERE'
|
||||
' remote_addr = ?',
|
||||
'AND id = ?',
|
||||
'AND ? - created < ?'
|
||||
], (comment["remote_addr"], comment["parent"],
|
||||
time.time(), self.max_age)).fetchall()
|
||||
|
||||
if len(rv) > 0:
|
||||
return False, "edit time frame is still open"
|
||||
|
||||
# require email if :param:`require-email` is enabled
|
||||
if self.conf.getboolean("require-email") and not comment.get("email"):
|
||||
return False, "email address required but not provided"
|
||||
|
||||
# require author if :param:`require-author` is enabled
|
||||
if self.conf.getboolean("require-author") and not comment.get("author"):
|
||||
return False, "author address required but not provided"
|
||||
|
||||
return True, ""
|
||||
|
||||
def _spam(self, uri, comment):
|
||||
return True, ""
|
@ -1,34 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
|
||||
def Thread(id, uri, title):
|
||||
return {
|
||||
"id": id,
|
||||
"uri": uri,
|
||||
"title": title
|
||||
}
|
||||
|
||||
|
||||
class Threads(object):
|
||||
|
||||
def __init__(self, db):
|
||||
|
||||
self.db = db
|
||||
self.db.execute([
|
||||
'CREATE TABLE IF NOT EXISTS threads (',
|
||||
' id INTEGER PRIMARY KEY, uri VARCHAR(256) UNIQUE, title VARCHAR(256))'])
|
||||
|
||||
def __contains__(self, uri):
|
||||
return self.db.execute("SELECT title FROM threads WHERE uri=?", (uri, )) \
|
||||
.fetchone() is not None
|
||||
|
||||
def __getitem__(self, uri):
|
||||
return Thread(*self.db.execute("SELECT * FROM threads WHERE uri=?", (uri, )).fetchone())
|
||||
|
||||
def get(self, id):
|
||||
return Thread(*self.db.execute("SELECT * FROM threads WHERE id=?", (id, )).fetchone())
|
||||
|
||||
def new(self, uri, title):
|
||||
self.db.execute(
|
||||
"INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title))
|
||||
return self[uri]
|
@ -7,7 +7,6 @@
|
||||
<div id="page" style="text-align:center;">
|
||||
<div id="wrapper" style="width: 900px; text-align: left; margin-left: auto; margin-right: auto;">
|
||||
<h2><a href="index.html">Isso Demo</a></h2>
|
||||
|
||||
<script src="../js/embed.min.js"></script>
|
||||
|
||||
<section id="isso-thread" data-title="Isso Test"></section>
|
||||
|
@ -8,7 +8,7 @@ import logging
|
||||
|
||||
from glob import glob
|
||||
|
||||
from werkzeug.middleware.dispatcher import DispatcherMiddleware
|
||||
from werkzeug.wsgi import DispatcherMiddleware
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from isso import dist, make_app, wsgi, config
|
||||
@ -22,16 +22,17 @@ class Dispatcher(DispatcherMiddleware):
|
||||
a relative URI, e.g. /foo.example and /other.bar.
|
||||
"""
|
||||
|
||||
conf = os.path.join(dist.location, "isso", "defaults.ini")
|
||||
|
||||
def __init__(self, *confs):
|
||||
|
||||
self.isso = {}
|
||||
|
||||
default = os.path.join(
|
||||
dist.location, dist.project_name, "defaults.ini")
|
||||
for i, path in enumerate(confs):
|
||||
conf = config.load(default, path)
|
||||
for path in confs:
|
||||
conf = config.load(Dispatcher.conf, path)
|
||||
|
||||
if not conf.get("general", "name"):
|
||||
logger.warn("unable to dispatch %r, no 'name' set", confs[i])
|
||||
logger.warn("unable to dispatch %r, no 'name' set", path)
|
||||
continue
|
||||
|
||||
self.isso["/" + conf.get("general", "name")] = make_app(conf)
|
||||
@ -46,8 +47,7 @@ class Dispatcher(DispatcherMiddleware):
|
||||
return super(Dispatcher, self).__call__(environ, start_response)
|
||||
|
||||
def default(self, environ, start_response):
|
||||
resp = Response("\n".join(self.isso.keys()),
|
||||
404, content_type="text/plain")
|
||||
resp = Response("\n".join(self.isso.keys()), 404, content_type="text/plain")
|
||||
return resp(environ, start_response)
|
||||
|
||||
|
||||
|
@ -14,11 +14,6 @@ from email.utils import formatdate
|
||||
from email.header import Header
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
except ImportError:
|
||||
from urllib import quote
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger("isso")
|
||||
|
||||
@ -36,14 +31,34 @@ else:
|
||||
from _thread import start_new_thread
|
||||
|
||||
|
||||
class SMTPConnection(object):
|
||||
class SMTP(object):
|
||||
|
||||
def __init__(self, conf):
|
||||
self.conf = conf
|
||||
def __init__(self, isso):
|
||||
|
||||
self.isso = isso
|
||||
self.conf = isso.conf.section("smtp")
|
||||
|
||||
# test SMTP connectivity
|
||||
try:
|
||||
with self:
|
||||
logger.info("connected to SMTP server")
|
||||
except (socket.error, smtplib.SMTPException):
|
||||
logger.exception("unable to connect to SMTP server")
|
||||
|
||||
if uwsgi:
|
||||
def spooler(args):
|
||||
try:
|
||||
self._sendmail(args[b"subject"].decode("utf-8"),
|
||||
args["body"].decode("utf-8"))
|
||||
except smtplib.SMTPConnectError:
|
||||
return uwsgi.SPOOL_RETRY
|
||||
else:
|
||||
return uwsgi.SPOOL_OK
|
||||
|
||||
uwsgi.spooler = spooler
|
||||
|
||||
def __enter__(self):
|
||||
klass = (smtplib.SMTP_SSL if self.conf.get(
|
||||
'security') == 'ssl' else smtplib.SMTP)
|
||||
klass = (smtplib.SMTP_SSL if self.conf.get('security') == 'ssl' else smtplib.SMTP)
|
||||
self.client = klass(host=self.conf.get('host'),
|
||||
port=self.conf.getint('port'),
|
||||
timeout=self.conf.getint('timeout'))
|
||||
@ -55,67 +70,24 @@ class SMTPConnection(object):
|
||||
else:
|
||||
self.client.starttls()
|
||||
|
||||
username = self.conf.get('username')
|
||||
password = self.conf.get('password')
|
||||
if username and password:
|
||||
if PY2K:
|
||||
username = username.encode('ascii')
|
||||
password = password.encode('ascii')
|
||||
|
||||
self.client.login(username, password)
|
||||
if self.conf.get('username') and self.conf.get('password'):
|
||||
self.client.login(self.conf.get('username'),
|
||||
self.conf.get('password'))
|
||||
|
||||
return self.client
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.client.quit()
|
||||
|
||||
|
||||
class SMTP(object):
|
||||
|
||||
def __init__(self, isso):
|
||||
|
||||
self.isso = isso
|
||||
self.conf = isso.conf.section("smtp")
|
||||
self.public_endpoint = isso.conf.get("server", "public-endpoint")
|
||||
# rstrips potential trailing '/', without having to `repr` the `local` object.
|
||||
if self.public_endpoint:
|
||||
self.public_endpoint = self.public_endpoint.rstrip('/')
|
||||
else:
|
||||
self.public_endpoint = local("host")
|
||||
|
||||
self.admin_notify = any((n in ("smtp", "SMTP")) for n in isso.conf.getlist("general", "notify"))
|
||||
self.reply_notify = isso.conf.getboolean("general", "reply-notifications")
|
||||
|
||||
# test SMTP connectivity
|
||||
try:
|
||||
with SMTPConnection(self.conf):
|
||||
logger.info("connected to SMTP server")
|
||||
except (socket.error, smtplib.SMTPException):
|
||||
logger.exception("unable to connect to SMTP server")
|
||||
|
||||
if uwsgi:
|
||||
def spooler(args):
|
||||
try:
|
||||
self._sendmail(args[b"subject"].decode("utf-8"),
|
||||
args["body"].decode("utf-8"),
|
||||
args[b"to"].decode("utf-8"))
|
||||
except smtplib.SMTPConnectError:
|
||||
return uwsgi.SPOOL_RETRY
|
||||
else:
|
||||
return uwsgi.SPOOL_OK
|
||||
|
||||
uwsgi.spooler = spooler
|
||||
|
||||
def __iter__(self):
|
||||
yield "comments.new:after-save", self.notify_new
|
||||
yield "comments.activate", self.notify_activated
|
||||
yield "comments.new:after-save", self.notify
|
||||
|
||||
def format(self, thread, comment, parent_comment, recipient=None, admin=False):
|
||||
def format(self, thread, comment):
|
||||
|
||||
rv = io.StringIO()
|
||||
|
||||
author = comment["author"] or "Anonymous"
|
||||
if admin and comment["email"]:
|
||||
if comment["email"]:
|
||||
author += " <%s>" % comment["email"]
|
||||
|
||||
rv.write(author + " wrote:\n")
|
||||
@ -123,80 +95,39 @@ class SMTP(object):
|
||||
rv.write(comment["text"] + "\n")
|
||||
rv.write("\n")
|
||||
|
||||
if admin:
|
||||
if comment["website"]:
|
||||
rv.write("User's URL: %s\n" % comment["website"])
|
||||
if comment["website"]:
|
||||
rv.write("User's URL: %s\n" % comment["website"])
|
||||
|
||||
rv.write("IP address: %s\n" % comment["remote_addr"])
|
||||
|
||||
rv.write("Link to comment: %s\n" %
|
||||
(local("origin") + thread["uri"] + "#isso-%i" % comment["id"]))
|
||||
rv.write("IP address: %s\n" % comment["remote_addr"])
|
||||
rv.write("Link to comment: %s\n" % (local("origin") + thread["uri"] + "#isso-%i" % comment["id"]))
|
||||
rv.write("\n")
|
||||
|
||||
uri = local("host") + "/id/%i" % comment["id"]
|
||||
key = self.isso.sign(comment["id"])
|
||||
|
||||
rv.write("---\n")
|
||||
rv.write("Delete comment: %s\n" % (uri + "/delete/" + key))
|
||||
|
||||
if admin:
|
||||
uri = self.public_endpoint + "/id/%i" % comment["id"]
|
||||
key = self.isso.sign(comment["id"])
|
||||
|
||||
rv.write("Delete comment: %s\n" % (uri + "/delete/" + key))
|
||||
|
||||
if comment["mode"] == 2:
|
||||
rv.write("Activate comment: %s\n" % (uri + "/activate/" + key))
|
||||
|
||||
else:
|
||||
uri = self.public_endpoint + "/id/%i" % parent_comment["id"]
|
||||
key = self.isso.sign(('unsubscribe', recipient))
|
||||
|
||||
rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + quote(recipient) + "/" + key))
|
||||
if comment["mode"] == 2:
|
||||
rv.write("Activate comment: %s\n" % (uri + "/activate/" + key))
|
||||
|
||||
rv.seek(0)
|
||||
return rv.read()
|
||||
|
||||
def notify_new(self, thread, comment):
|
||||
if self.admin_notify:
|
||||
body = self.format(thread, comment, None, admin=True)
|
||||
subject = "New comment posted"
|
||||
if thread['title']:
|
||||
subject = "%s on %s" % (subject, thread["title"])
|
||||
self.sendmail(subject, body, thread, comment)
|
||||
def notify(self, thread, comment):
|
||||
|
||||
if comment["mode"] == 1:
|
||||
self.notify_users(thread, comment)
|
||||
body = self.format(thread, comment)
|
||||
|
||||
def notify_activated(self, thread, comment):
|
||||
self.notify_users(thread, comment)
|
||||
|
||||
def notify_users(self, thread, comment):
|
||||
if self.reply_notify and "parent" in comment and comment["parent"] is not None:
|
||||
# Notify interested authors that a new comment is posted
|
||||
notified = []
|
||||
parent_comment = self.isso.db.comments.get(comment["parent"])
|
||||
comments_to_notify = [parent_comment] if parent_comment is not None else []
|
||||
comments_to_notify += self.isso.db.comments.fetch(thread["uri"], mode=1, parent=comment["parent"])
|
||||
for comment_to_notify in comments_to_notify:
|
||||
email = comment_to_notify["email"]
|
||||
if "email" in comment_to_notify and comment_to_notify["notification"] and email not in notified \
|
||||
and comment_to_notify["id"] != comment["id"] and email != comment["email"]:
|
||||
body = self.format(thread, comment, parent_comment, email, admin=False)
|
||||
subject = "Re: New comment posted on %s" % thread["title"]
|
||||
self.sendmail(subject, body, thread, comment, to=email)
|
||||
notified.append(email)
|
||||
|
||||
def sendmail(self, subject, body, thread, comment, to=None):
|
||||
to = to or self.conf.get("to")
|
||||
if not subject:
|
||||
# Fallback, just in case as an empty subject does not work
|
||||
subject = 'isso notification'
|
||||
if uwsgi:
|
||||
uwsgi.spool({b"subject": subject.encode("utf-8"),
|
||||
b"body": body.encode("utf-8"),
|
||||
b"to": to.encode("utf-8")})
|
||||
uwsgi.spool({b"subject": thread["title"].encode("utf-8"),
|
||||
b"body": body.encode("utf-8")})
|
||||
else:
|
||||
start_new_thread(self._retry, (subject, body, to))
|
||||
start_new_thread(self._retry, (thread["title"], body))
|
||||
|
||||
def _sendmail(self, subject, body, to_addr):
|
||||
def _sendmail(self, subject, body):
|
||||
|
||||
from_addr = self.conf.get("from")
|
||||
to_addr = self.conf.get("to")
|
||||
|
||||
msg = MIMEText(body, 'plain', 'utf-8')
|
||||
msg['From'] = from_addr
|
||||
@ -204,13 +135,13 @@ class SMTP(object):
|
||||
msg['Date'] = formatdate(localtime=True)
|
||||
msg['Subject'] = Header(subject, 'utf-8')
|
||||
|
||||
with SMTPConnection(self.conf) as con:
|
||||
with self as con:
|
||||
con.sendmail(from_addr, to_addr, msg.as_string())
|
||||
|
||||
def _retry(self, subject, body, to):
|
||||
def _retry(self, subject, body):
|
||||
for x in range(5):
|
||||
try:
|
||||
self._sendmail(subject, body, to)
|
||||
self._sendmail(subject, body)
|
||||
except smtplib.SMTPConnectError:
|
||||
time.sleep(60)
|
||||
else:
|
||||
@ -237,11 +168,10 @@ class Stdout(object):
|
||||
logger.info("comment created: %s", json.dumps(comment))
|
||||
|
||||
def _edit_comment(self, comment):
|
||||
logger.info('comment %i edited: %s',
|
||||
comment["id"], json.dumps(comment))
|
||||
logger.info('comment %i edited: %s', comment["id"], json.dumps(comment))
|
||||
|
||||
def _delete_comment(self, id):
|
||||
logger.info('comment %i deleted', id)
|
||||
|
||||
def _activate_comment(self, thread, comment):
|
||||
logger.info("comment %(id)s activated" % thread)
|
||||
def _activate_comment(self, id):
|
||||
logger.info("comment %s activated" % id)
|
||||
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 11 KiB |
100
isso/js/admin.js
100
isso/js/admin.js
@ -1,100 +0,0 @@
|
||||
function ajax(req) {
|
||||
var r = new XMLHttpRequest();
|
||||
r.open(req.method, req.url, true);
|
||||
r.onreadystatechange = function () {
|
||||
if (r.readyState != 4 || r.status != 200) {
|
||||
if (req.failure) {
|
||||
req.failure();
|
||||
}
|
||||
return;
|
||||
}
|
||||
req.success(r.responseText);
|
||||
};
|
||||
r.send(req.data);
|
||||
}
|
||||
function fade(element) {
|
||||
var op = 1; // initial opacity
|
||||
var timer = setInterval(function () {
|
||||
if (op <= 0.1){
|
||||
clearInterval(timer);
|
||||
element.style.display = 'none';
|
||||
}
|
||||
element.style.opacity = op;
|
||||
element.style.filter = 'alpha(opacity=' + op * 100 + ")";
|
||||
op -= op * 0.1;
|
||||
}, 10);
|
||||
}
|
||||
function moderate(com_id, hash, action, isso_host_script) {
|
||||
ajax({method: "POST",
|
||||
url: isso_host_script + "/id/" + com_id + "/" + action + "/" + hash,
|
||||
success: function(){
|
||||
fade(document.getElementById("isso-" + com_id));
|
||||
}});
|
||||
}
|
||||
function edit(com_id, hash, author, email, website, comment, isso_host_script) {
|
||||
ajax({method: "POST",
|
||||
url: isso_host_script + "/id/" + com_id + "/edit/" + hash,
|
||||
data: JSON.stringify({text: comment,
|
||||
author: author,
|
||||
email: email,
|
||||
website: website}),
|
||||
success: function(ret){
|
||||
console.log("edit successed: ", ret);// TODO display some pretty stuff & update msg
|
||||
},
|
||||
error: function(ret){
|
||||
console.log("Error: ", ret); // TODO flash msg/notif
|
||||
}});
|
||||
}
|
||||
function validate_com(com_id, hash, isso_host_script) {
|
||||
moderate(com_id, hash, "activate", isso_host_script);
|
||||
}
|
||||
function delete_com(com_id, hash, isso_host_script) {
|
||||
moderate(com_id, hash, "delete", isso_host_script);
|
||||
}
|
||||
function unset_editable(elt_id) {
|
||||
var elt = document.getElementById(elt_id);
|
||||
if (elt) {
|
||||
elt.contentEditable = false;
|
||||
elt.classList.remove("editable");
|
||||
}
|
||||
}
|
||||
function set_editable(elt_id) {
|
||||
var elt = document.getElementById(elt_id);
|
||||
if (elt) {
|
||||
elt.contentEditable = true;
|
||||
elt.classList.add("editable");
|
||||
}
|
||||
}
|
||||
function start_edit(com_id) {
|
||||
var editable_elements = ['isso-author-' + com_id,
|
||||
'isso-email-' + com_id,
|
||||
'isso-website-' + com_id,
|
||||
'isso-text-' + com_id];
|
||||
for (var idx=0; idx <= editable_elements.length; idx++) {
|
||||
set_editable(editable_elements[idx]);
|
||||
}
|
||||
document.getElementById('edit-btn-' + com_id).classList.toggle('hidden');
|
||||
document.getElementById('stop-edit-btn-' + com_id).classList.toggle('hidden');
|
||||
document.getElementById('send-edit-btn-' + com_id).classList.toggle('hidden');
|
||||
}
|
||||
function stop_edit(com_id) {
|
||||
var editable_elements = ['isso-author-' + com_id,
|
||||
'isso-email-' + com_id,
|
||||
'isso-website-' + com_id,
|
||||
'isso-text-' + com_id];
|
||||
for (var idx=0; idx <= editable_elements.length; idx++) {
|
||||
unset_editable(editable_elements[idx]);
|
||||
}
|
||||
document.getElementById('edit-btn-' + com_id).classList.toggle('hidden');
|
||||
document.getElementById('stop-edit-btn-' + com_id).classList.toggle('hidden');
|
||||
document.getElementById('send-edit-btn-' + com_id).classList.toggle('hidden');
|
||||
}
|
||||
function send_edit(com_id, hash, isso_host_script) {
|
||||
var author = document.getElementById('isso-author-' + com_id).textContent;
|
||||
var email = document.getElementById('isso-email-' + com_id).textContent;
|
||||
var website = document.getElementById('isso-website-' + com_id).textContent;
|
||||
var comment = document.getElementById('isso-text-' + com_id).textContent;
|
||||
edit(com_id, hash, author, email, website, comment, isso_host_script);
|
||||
stop_edit(com_id);
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
|
||||
"use strict";
|
||||
|
||||
var salt = "Eech7co8Ohloopo9Ol6baimi",
|
||||
location = function() { return window.location.pathname };
|
||||
location = window.location.pathname;
|
||||
|
||||
var script, endpoint,
|
||||
js = document.getElementsByTagName("script");
|
||||
@ -52,9 +52,7 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
|
||||
}
|
||||
|
||||
if (xhr.status >= 500) {
|
||||
if (reject) {
|
||||
reject(xhr.body);
|
||||
}
|
||||
reject(xhr.body);
|
||||
} else {
|
||||
resolve({status: xhr.status, body: xhr.responseText});
|
||||
}
|
||||
@ -91,14 +89,8 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
|
||||
|
||||
var create = function(tid, data) {
|
||||
var deferred = Q.defer();
|
||||
curl("POST", endpoint + "/new?" + qs({uri: tid || location()}), JSON.stringify(data),
|
||||
function (rv) {
|
||||
if (rv.status === 201 || rv.status === 202) {
|
||||
deferred.resolve(JSON.parse(rv.body));
|
||||
} else {
|
||||
deferred.reject(rv.body);
|
||||
}
|
||||
});
|
||||
curl("POST", endpoint + "/new?" + qs({uri: tid || location}), JSON.stringify(data),
|
||||
function (rv) { deferred.resolve(JSON.parse(rv.body)); });
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
@ -142,7 +134,7 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
|
||||
if (typeof(nested_limit) === 'undefined') { nested_limit = "inf"; }
|
||||
if (typeof(parent) === 'undefined') { parent = null; }
|
||||
|
||||
var query_dict = {uri: tid || location(), after: lastcreated, parent: parent};
|
||||
var query_dict = {uri: tid || location, after: lastcreated, parent: parent};
|
||||
|
||||
if(limit !== "inf") {
|
||||
query_dict['limit'] = limit;
|
||||
@ -191,24 +183,6 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
|
||||
var feed = function(tid) {
|
||||
return endpoint + "/feed?" + qs({uri: tid || location()});
|
||||
};
|
||||
|
||||
var preview = function(text) {
|
||||
var deferred = Q.defer();
|
||||
curl("POST", endpoint + "/preview", JSON.stringify({text: text}),
|
||||
function(rv) {
|
||||
if (rv.status === 200) {
|
||||
deferred.resolve(JSON.parse(rv.body).text);
|
||||
} else {
|
||||
deferred.reject(rv.body);
|
||||
}
|
||||
});
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
return {
|
||||
endpoint: endpoint,
|
||||
salt: salt,
|
||||
@ -220,8 +194,6 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
|
||||
fetch: fetch,
|
||||
count: count,
|
||||
like: like,
|
||||
dislike: dislike,
|
||||
feed: feed,
|
||||
preview: preview
|
||||
dislike: dislike
|
||||
};
|
||||
});
|
||||
|
@ -5,20 +5,15 @@ define(function() {
|
||||
"css": true,
|
||||
"lang": (navigator.language || navigator.userLanguage).split("-")[0],
|
||||
"reply-to-self": false,
|
||||
"require-email": false,
|
||||
"require-author": false,
|
||||
"reply-notifications": false,
|
||||
"max-comments-top": "inf",
|
||||
"max-comments-nested": 5,
|
||||
"reveal-on-click": 5,
|
||||
"gravatar": false,
|
||||
"auth": false,
|
||||
"auth-sign-in-url": "",
|
||||
"avatar": true,
|
||||
"avatar-bg": "#f0f0f0",
|
||||
"avatar-fg": ["#9abf88", "#5698c4", "#e279a3", "#9163b6",
|
||||
"#be5168", "#f19670", "#e4bf80", "#447c69"].join(" "),
|
||||
"vote": true,
|
||||
"vote-levels": null,
|
||||
"feed": false
|
||||
"#be5168", "#f19670", "#e4bf80", "#447c69"].join(" ")
|
||||
};
|
||||
|
||||
var js = document.getElementsByTagName("script");
|
||||
|
@ -4,7 +4,7 @@ define(["app/api", "app/dom", "app/i18n"], function(api, $, i18n) {
|
||||
var objs = {};
|
||||
|
||||
$.each("a", function(el) {
|
||||
if (! el.href.match || ! el.href.match(/#isso-thread$/)) {
|
||||
if (! el.href.match(/#isso-thread$/)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -2,137 +2,101 @@ define(function() {
|
||||
|
||||
"use strict";
|
||||
|
||||
function Element(node) {
|
||||
this.obj = node;
|
||||
window.Element.prototype.replace = function(el) {
|
||||
var element = DOM.htmlify(el);
|
||||
this.parentNode.replaceChild(element, this);
|
||||
return element;
|
||||
};
|
||||
|
||||
this.replace = function (el) {
|
||||
var element = DOM.htmlify(el);
|
||||
node.parentNode.replaceChild(element.obj, node);
|
||||
return element;
|
||||
};
|
||||
window.Element.prototype.prepend = function(el) {
|
||||
var element = DOM.htmlify(el);
|
||||
this.insertBefore(element, this.firstChild);
|
||||
return element;
|
||||
};
|
||||
|
||||
this.prepend = function (el) {
|
||||
var element = DOM.htmlify(el);
|
||||
node.insertBefore(element.obj, node.firstChild);
|
||||
return element;
|
||||
};
|
||||
window.Element.prototype.append = function(el) {
|
||||
var element = DOM.htmlify(el);
|
||||
this.appendChild(element);
|
||||
return element;
|
||||
};
|
||||
|
||||
this.append = function (el) {
|
||||
var element = DOM.htmlify(el);
|
||||
node.appendChild(element.obj);
|
||||
return element;
|
||||
};
|
||||
window.Element.prototype.insertAfter = function(el) {
|
||||
var element = DOM.htmlify(el);
|
||||
this.parentNode.insertBefore(element, this.nextSibling);
|
||||
return element;
|
||||
};
|
||||
|
||||
this.insertAfter = function(el) {
|
||||
var element = DOM.htmlify(el);
|
||||
node.parentNode.insertBefore(element.obj, node.nextSibling);
|
||||
return element;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shortcut for `Element.addEventListener`, prevents default event
|
||||
* by default, set :param prevents: to `false` to change that behavior.
|
||||
window.Element.prototype.on = function(type, listener, prevent) {
|
||||
/*
|
||||
Shortcut for `Element.addEventListener`, prevents default event
|
||||
by default, set :param prevents: to `false` to change that behavior.
|
||||
*/
|
||||
this.on = function(type, listener, prevent) {
|
||||
node.addEventListener(type, function(event) {
|
||||
listener(event);
|
||||
if (prevent === undefined || prevent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle between two internal states on event :param type: e.g. to
|
||||
* cycle form visibility. Callback :param a: is called on first event,
|
||||
* :param b: next time.
|
||||
*
|
||||
* You can skip to the next state without executing the callback with
|
||||
* `toggler.next()`. You can prevent a cycle when you call `toggler.wait()`
|
||||
* during an event.
|
||||
*/
|
||||
this.toggle = function(type, a, b) {
|
||||
|
||||
var toggler = new Toggle(a, b);
|
||||
this.on(type, function() {
|
||||
toggler.next();
|
||||
});
|
||||
};
|
||||
|
||||
this.detach = function() {
|
||||
// Detach an element from the DOM and return it.
|
||||
node.parentNode.removeChild(this.obj);
|
||||
return this;
|
||||
};
|
||||
|
||||
this.remove = function() {
|
||||
// IE quirks
|
||||
node.parentNode.removeChild(this.obj);
|
||||
};
|
||||
|
||||
this.show = function() {
|
||||
node.style.display = "block";
|
||||
};
|
||||
|
||||
this.hide = function() {
|
||||
node.style.display = "none";
|
||||
};
|
||||
|
||||
this.setText = function(text) {
|
||||
node.textContent = text;
|
||||
};
|
||||
|
||||
this.setHtml = function(html) {
|
||||
node.innerHTML = html;
|
||||
};
|
||||
|
||||
this.blur = function() { node.blur() };
|
||||
this.focus = function() { node.focus() };
|
||||
this.scrollIntoView = function(args) { node.scrollIntoView(args) };
|
||||
|
||||
this.checked = function() { return node.checked; };
|
||||
|
||||
this.setAttribute = function(key, value) { node.setAttribute(key, value) };
|
||||
this.getAttribute = function(key) { return node.getAttribute(key) };
|
||||
|
||||
this.classList = node.classList;
|
||||
|
||||
Object.defineProperties(this, {
|
||||
"textContent": {
|
||||
get: function() { return node.textContent; },
|
||||
set: function(textContent) { node.textContent = textContent; }
|
||||
},
|
||||
"innerHTML": {
|
||||
get: function() { return node.innerHTML; },
|
||||
set: function(innerHTML) { node.innerHTML = innerHTML; }
|
||||
},
|
||||
"value": {
|
||||
get: function() { return node.value; },
|
||||
set: function(value) { node.value = value; }
|
||||
},
|
||||
"placeholder": {
|
||||
get: function() { return node.placeholder; },
|
||||
set: function(placeholder) { node.placeholder = placeholder; }
|
||||
this.addEventListener(type, function(event) {
|
||||
listener(event);
|
||||
if (prevent === undefined || prevent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var Toggle = function(a, b) {
|
||||
this.state = false;
|
||||
window.Element.prototype.toggle = function(type, on, off) {
|
||||
/*
|
||||
Toggle between two internal states on event :param type: e.g. to
|
||||
cycle form visibility. Callback :param on: is called on first event,
|
||||
:param off: next time.
|
||||
|
||||
this.next = function() {
|
||||
You can skip to the next state without executing the callback with
|
||||
`toggler.next()`. You can prevent a cycle when you call `toggler.wait()`
|
||||
during an event.
|
||||
*/
|
||||
|
||||
function Toggle(el, on, off) {
|
||||
this.state = false;
|
||||
this.el = el;
|
||||
this.on = on;
|
||||
this.off = off;
|
||||
}
|
||||
|
||||
Toggle.prototype.next = function next() {
|
||||
if (! this.state) {
|
||||
this.state = true;
|
||||
a(this);
|
||||
this.on(this);
|
||||
} else {
|
||||
this.state = false;
|
||||
b(this);
|
||||
this.off(this);
|
||||
}
|
||||
};
|
||||
|
||||
this.wait = function() {
|
||||
Toggle.prototype.wait = function wait() {
|
||||
this.state = ! this.state;
|
||||
};
|
||||
|
||||
var toggler = new Toggle(this, on, off);
|
||||
this.on(type, function() {
|
||||
toggler.next();
|
||||
});
|
||||
};
|
||||
|
||||
window.Element.prototype.detach = function() {
|
||||
/*
|
||||
Detach an element from the DOM and return it.
|
||||
*/
|
||||
|
||||
this.parentNode.removeChild(this);
|
||||
return this;
|
||||
};
|
||||
|
||||
window.Element.prototype.remove = function() {
|
||||
// Mimimi, I am IE and I am so retarded, mimimi.
|
||||
this.parentNode.removeChild(this);
|
||||
};
|
||||
|
||||
window.Element.prototype.show = function() {
|
||||
this.style.display = "block";
|
||||
};
|
||||
|
||||
window.Element.prototype.hide = function() {
|
||||
this.style.display = "none";
|
||||
};
|
||||
|
||||
var DOM = function(query, root, single) {
|
||||
@ -151,43 +115,31 @@ define(function() {
|
||||
root = window.document;
|
||||
}
|
||||
|
||||
if (root instanceof Element) {
|
||||
root = root.obj;
|
||||
}
|
||||
var elements = [].slice.call(root.querySelectorAll(query), 0);
|
||||
var elements = root.querySelectorAll(query);
|
||||
|
||||
if (elements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (elements.length === 1 && single) {
|
||||
return new Element(elements[0]);
|
||||
return elements[0];
|
||||
}
|
||||
|
||||
// convert NodeList to Array
|
||||
elements = [].slice.call(elements, 0);
|
||||
|
||||
return elements.map(function(el) {
|
||||
return new Element(el);
|
||||
});
|
||||
return elements;
|
||||
};
|
||||
|
||||
DOM.htmlify = function(el) {
|
||||
DOM.htmlify = function(html) {
|
||||
/*
|
||||
Convert :param html: into an Element (if not already).
|
||||
*/
|
||||
*/
|
||||
|
||||
if (el instanceof Element) {
|
||||
return el;
|
||||
}
|
||||
|
||||
if (el instanceof window.Element) {
|
||||
return new Element(el);
|
||||
if (html instanceof window.Element) {
|
||||
return html;
|
||||
}
|
||||
|
||||
var wrapper = DOM.new("div");
|
||||
wrapper.innerHTML = el;
|
||||
return new Element(wrapper.firstChild);
|
||||
wrapper.innerHTML = html;
|
||||
return wrapper.firstChild;
|
||||
};
|
||||
|
||||
DOM.new = function(tag, content) {
|
||||
@ -207,13 +159,10 @@ define(function() {
|
||||
el.href = "#";
|
||||
}
|
||||
|
||||
if (!content && content !== 0) {
|
||||
content = "";
|
||||
}
|
||||
if (["TEXTAREA", "INPUT"].indexOf(el.nodeName) > -1) {
|
||||
el.value = content;
|
||||
el.value = content || "";
|
||||
} else {
|
||||
el.textContent = content;
|
||||
el.textContent = content || "";
|
||||
}
|
||||
return el;
|
||||
};
|
||||
@ -224,4 +173,4 @@ define(function() {
|
||||
};
|
||||
|
||||
return DOM;
|
||||
});
|
||||
});
|
@ -10,7 +10,7 @@ define(function() {
|
||||
};
|
||||
|
||||
Offset.prototype.localTime = function() {
|
||||
return new Date((new Date()).getTime() - this.values.reduce(
|
||||
return new Date((new Date()).getTime() + this.values.reduce(
|
||||
function(a, b) { return a + b; }) / this.values.length);
|
||||
};
|
||||
|
||||
@ -18,4 +18,4 @@ define(function() {
|
||||
offset: new Offset()
|
||||
};
|
||||
|
||||
});
|
||||
});
|
@ -1,34 +1,13 @@
|
||||
define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/da",
|
||||
"app/i18n/de", "app/i18n/en", "app/i18n/fa", "app/i18n/fi",
|
||||
"app/i18n/fr", "app/i18n/hr", "app/i18n/hu", "app/i18n/ru", "app/i18n/it",
|
||||
"app/i18n/eo", "app/i18n/oc", "app/i18n/pl", "app/i18n/pt_BR", "app/i18n/sk", "app/i18n/sv", "app/i18n/nl", "app/i18n/el_GR",
|
||||
"app/i18n/es", "app/i18n/vi", "app/i18n/zh_CN", "app/i18n/zh_CN", "app/i18n/zh_TW"],
|
||||
function(config, bg, cs, da, de, en, fa, fi, fr, hr, hu, ru, it, eo, oc, pl, pt_BR, sk, sv, nl, el, es, vi, zh, zh_CN, zh_TW) {
|
||||
define(["app/config", "app/i18n/de", "app/i18n/en", "app/i18n/fr", "app/i18n/ru", "app/i18n/it", "app/i18n/eo"], function(config, de, en, fr, ru, it, eo) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var pluralforms = function(lang) {
|
||||
switch (lang) {
|
||||
case "bg":
|
||||
case "cs":
|
||||
case "da":
|
||||
case "de":
|
||||
case "el":
|
||||
case "en":
|
||||
case "es":
|
||||
case "eo":
|
||||
case "fa":
|
||||
case "fi":
|
||||
case "hr":
|
||||
case "hu":
|
||||
case "de":
|
||||
case "it":
|
||||
case "pt_BR":
|
||||
case "sv":
|
||||
case "nl":
|
||||
case "vi":
|
||||
case "zh":
|
||||
case "zh_CN":
|
||||
case "zh_TW":
|
||||
case "eo":
|
||||
return function(msgs, n) {
|
||||
return msgs[n === 1 ? 0 : 1];
|
||||
};
|
||||
@ -46,30 +25,6 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/da",
|
||||
return typeof msgs[2] !== "undefined" ? msgs[2] : msgs[1];
|
||||
}
|
||||
};
|
||||
case "oc":
|
||||
return function(msgs, n) {
|
||||
return msgs[n > 1 ? 1 : 0];
|
||||
};
|
||||
case "pl":
|
||||
return function(msgs, n) {
|
||||
if (n === 1) {
|
||||
return msgs[0];
|
||||
} else if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) {
|
||||
return msgs[1];
|
||||
} else {
|
||||
return typeof msgs[2] !== "undefined" ? msgs[2] : msgs[1];
|
||||
}
|
||||
};
|
||||
case "sk":
|
||||
return function(msgs, n) {
|
||||
if (n === 1) {
|
||||
return msgs[0];
|
||||
} else if (n === 2 || n === 3 || n === 4) {
|
||||
return msgs[1];
|
||||
} else {
|
||||
return typeof msgs[2] !== "undefined" ? msgs[2] : msgs[1];
|
||||
}
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@ -84,41 +39,18 @@ define(["app/config", "app/i18n/bg", "app/i18n/cs", "app/i18n/da",
|
||||
}
|
||||
|
||||
var catalogue = {
|
||||
bg: bg,
|
||||
cs: cs,
|
||||
da: da,
|
||||
de: de,
|
||||
el: el,
|
||||
en: en,
|
||||
eo: eo,
|
||||
es: es,
|
||||
fa: fa,
|
||||
fi: fi,
|
||||
fr: fr,
|
||||
it: it,
|
||||
hr: hr,
|
||||
hu: hu,
|
||||
oc: oc,
|
||||
pl: pl,
|
||||
pt: pt_BR,
|
||||
pt_BR: pt_BR,
|
||||
ru: ru,
|
||||
sk: sk,
|
||||
sv: sv,
|
||||
nl: nl,
|
||||
vi: vi,
|
||||
zh: zh_CN,
|
||||
zh_CN: zh_CN,
|
||||
zh_TW: zh_TW
|
||||
it: it,
|
||||
eo: eo
|
||||
};
|
||||
|
||||
var plural = pluralforms(lang);
|
||||
|
||||
var translate = function(msgid) {
|
||||
return config[msgid + '-text-' + lang] ||
|
||||
catalogue[lang][msgid] ||
|
||||
en[msgid] ||
|
||||
"???";
|
||||
return catalogue[lang][msgid] || en[msgid] || "???";
|
||||
};
|
||||
|
||||
var pluralize = function(msgid, n) {
|
||||
|
@ -1,29 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Въведете коментара си тук (поне 3 знака)",
|
||||
"postbox-author": "Име/псевдоним (незадължително)",
|
||||
"postbox-email": "Ел. поща (незадължително)",
|
||||
"postbox-website": "Уебсайт (незадължително)",
|
||||
"postbox-preview": "преглед",
|
||||
"postbox-edit": "Редактиране",
|
||||
"postbox-submit": "Публикуване",
|
||||
"num-comments": "1 коментар\n{{ n }} коментара",
|
||||
"no-comments": "Все още няма коментари",
|
||||
"comment-reply": "Отговор",
|
||||
"comment-edit": "Редактиране",
|
||||
"comment-save": "Запис",
|
||||
"comment-delete": "Изтриване",
|
||||
"comment-confirm": "Потвърждение",
|
||||
"comment-close": "Затваряне",
|
||||
"comment-cancel": "Отказ",
|
||||
"comment-deleted": "Коментарът е изтрит.",
|
||||
"comment-queued": "Коментарът чака на опашката за модериране.",
|
||||
"comment-anonymous": "анонимен",
|
||||
"comment-hidden": "{{ n }} скрити",
|
||||
"date-now": "сега",
|
||||
"date-minute": "преди 1 минута\nпреди {{ n }} минути",
|
||||
"date-hour": "преди 1 час\nпреди {{ n }} часа",
|
||||
"date-day": "вчера\nпреди {{ n }} дни",
|
||||
"date-week": "миналата седмица\nпреди {{ n }} седмици",
|
||||
"date-month": "миналия месец\nпреди {{ n }} месеца",
|
||||
"date-year": "миналата година\nпреди {{ n }} години"
|
||||
});
|
@ -1,29 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Sem napiště svůj komentář (nejméně 3 znaky)",
|
||||
"postbox-author": "Jméno (nepovinné)",
|
||||
"postbox-email": "E-mail (nepovinný)",
|
||||
"postbox-website": "Web (nepovinný)",
|
||||
"postbox-preview": "Náhled",
|
||||
"postbox-edit": "Upravit",
|
||||
"postbox-submit": "Publikovat",
|
||||
"num-comments": "Jeden komentář\n{{ n }} Komentářů",
|
||||
"no-comments": "Zatím bez komentářů",
|
||||
"comment-reply": "Odpovědět",
|
||||
"comment-edit": "Upravit",
|
||||
"comment-save": "Uložit",
|
||||
"comment-delete": "Smazat",
|
||||
"comment-confirm": "Potvrdit",
|
||||
"comment-close": "Zavřít",
|
||||
"comment-cancel": "Zrušit",
|
||||
"comment-deleted": "Komentář smazán",
|
||||
"comment-queued": "Komentář ve frontě na schválení",
|
||||
"comment-anonymous": "Anonym",
|
||||
"comment-hidden": "{{ n }} skryto",
|
||||
"date-now": "právě teď",
|
||||
"date-minute": "před minutou\npřed {{ n }} minutami",
|
||||
"date-hour": "před hodinou\npřed {{ n }} hodinami",
|
||||
"date-day": "včera\npřed {{ n }} dny",
|
||||
"date-week": "minulý týden\npřed {{ n }} týdny",
|
||||
"date-month": "minulý měsíc\npřed {{ n }} měsíci",
|
||||
"date-year": "minulý rok\npřed {{ n }} lety"
|
||||
});
|
@ -1,32 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Type Comment Here (at least 3 chars)",
|
||||
"postbox-author": "Name (optional)",
|
||||
"postbox-email": "E-mail (optional)",
|
||||
"postbox-website": "Website (optional)",
|
||||
"postbox-preview": "Eksempel",
|
||||
"postbox-edit": "Rediger",
|
||||
"postbox-submit": "Submit",
|
||||
|
||||
"num-comments": "One Comment\n{{ n }} Comments",
|
||||
"no-comments": "Ingen kommentarer endnu",
|
||||
|
||||
"comment-reply": "Svar",
|
||||
"comment-edit": "Rediger",
|
||||
"comment-save": "Gem",
|
||||
"comment-delete": "Fjern",
|
||||
"comment-confirm": "Bekræft",
|
||||
"comment-close": "Luk",
|
||||
"comment-cancel": "Annuller",
|
||||
"comment-deleted": "Kommentar slettet.",
|
||||
"comment-queued": "Kommentar i kø for moderation.",
|
||||
"comment-anonymous": "Anonym",
|
||||
"comment-hidden": "{{ n }} Skjult",
|
||||
|
||||
"date-now": "lige nu",
|
||||
"date-minute": "et minut siden\n{{ n }} minutter siden",
|
||||
"date-hour": "en time siden\n{{ n }} timer siden",
|
||||
"date-day": "Igår\n{{ n }} dage siden",
|
||||
"date-week": "sidste uge\n{{ n }} uger siden",
|
||||
"date-month": "sidste måned\n{{ n }} måneder siden",
|
||||
"date-year": "sidste år\n{{ n }} år siden"
|
||||
});
|
@ -1,14 +1,11 @@
|
||||
define({
|
||||
"postbox-text": "Kommentar hier eingeben (mindestens 3 Zeichen)",
|
||||
"postbox-text": "Kommentar hier eintippen (mindestens 3 Zeichen)",
|
||||
"postbox-author": "Name (optional)",
|
||||
"postbox-email": "E-Mail (optional)",
|
||||
"postbox-email": "Email (optional)",
|
||||
"postbox-website": "Website (optional)",
|
||||
"postbox-preview": "Vorschau",
|
||||
"postbox-edit": "Bearbeiten",
|
||||
"postbox-submit": "Abschicken",
|
||||
"postbox-notification": "wenn auf meinen Kommentar geantwortet wird, möchte ich eine E-Mail bekommen",
|
||||
"num-comments": "1 Kommentar\n{{ n }} Kommentare",
|
||||
"no-comments": "Bisher keine Kommentare",
|
||||
"no-comments": "Keine Kommentare bis jetzt",
|
||||
"comment-reply": "Antworten",
|
||||
"comment-edit": "Bearbeiten",
|
||||
"comment-save": "Speichern",
|
||||
@ -20,7 +17,7 @@ define({
|
||||
"comment-queued": "Kommentar muss noch freigeschaltet werden.",
|
||||
"comment-anonymous": "Anonym",
|
||||
"comment-hidden": "{{ n }} versteckt",
|
||||
"date-now": "eben gerade",
|
||||
"date-now": "eben jetzt",
|
||||
"date-minute": "vor einer Minute\nvor {{ n }} Minuten",
|
||||
"date-hour": "vor einer Stunde\nvor {{ n }} Stunden",
|
||||
"date-day": "Gestern\nvor {{ n }} Tagen",
|
||||
|
@ -1,29 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Γράψτε το σχόλιο εδώ (τουλάχιστον 3 χαρακτήρες)",
|
||||
"postbox-author": "Όνομα (προαιρετικό)",
|
||||
"postbox-email": "E-mail (προαιρετικό)",
|
||||
"postbox-website": "Ιστοσελίδα (προαιρετικό)",
|
||||
"postbox-preview": "Πρεμιέρα",
|
||||
"postbox-edit": "Επεξεργασία",
|
||||
"postbox-submit": "Υποβολή",
|
||||
"num-comments": "Ένα σχόλιο\n{{ n }} σχόλια",
|
||||
"no-comments": "Δεν υπάρχουν σχόλια",
|
||||
"comment-reply": "Απάντηση",
|
||||
"comment-edit": "Επεξεργασία",
|
||||
"comment-save": "Αποθήκευση",
|
||||
"comment-delete": "Διαγραφή",
|
||||
"comment-confirm": "Επιβεβαίωση",
|
||||
"comment-close": "Κλείσιμο",
|
||||
"comment-cancel": "Ακύρωση",
|
||||
"comment-deleted": "Διαγραμμένο σχόλιο ",
|
||||
"comment-queued": "Το σχόλιο αναμένει έγκριση",
|
||||
"comment-anonymous": "Ανώνυμος",
|
||||
"comment-hidden": "{{ n }} Κρυμμένα",
|
||||
"date-now": "τώρα",
|
||||
"date-minute": "πριν ένα λεπτό\nπριν {{ n }} λεπτά",
|
||||
"date-hour": "πριν μία ώρα\nπριν {{ n }} ώρες",
|
||||
"date-day": "Χτες\nπριν {{ n }} μέρες",
|
||||
"date-week": "την προηγούμενη εβδομάδα\nπριν {{ n }} εβδομάδες",
|
||||
"date-month": "τον προηγούμενο μήνα\nπριν {{ n }} μήνες",
|
||||
"date-year": "πέρυσι\nπριν {{ n }} χρόνια"
|
||||
});
|
@ -3,14 +3,13 @@ define({
|
||||
"postbox-author": "Name (optional)",
|
||||
"postbox-email": "E-mail (optional)",
|
||||
"postbox-website": "Website (optional)",
|
||||
"postbox-preview": "Preview",
|
||||
"postbox-edit": "Edit",
|
||||
"postbox-submit": "Submit",
|
||||
"postbox-notification": "Subscribe to email notification of replies",
|
||||
"postbox-submit": "submit",
|
||||
"postbox-auth-required": "Authentication required to comment.",
|
||||
"postbox-auth-sign-in": "Sign-in",
|
||||
"postbox-auth-commenting-as": "Commenting as",
|
||||
|
||||
"num-comments": "One Comment\n{{ n }} Comments",
|
||||
"no-comments": "No Comments Yet",
|
||||
"atom-feed": "Atom feed",
|
||||
|
||||
"comment-reply": "Reply",
|
||||
"comment-edit": "Edit",
|
||||
|
@ -3,8 +3,6 @@ define({
|
||||
"postbox-author": "Nomo (malnepra)",
|
||||
"postbox-email": "Retadreso (malnepra)",
|
||||
"postbox-website": "Retejo (malnepra)",
|
||||
"postbox-preview": "Antaŭrigardo",
|
||||
"postbox-edit": "Redaktu",
|
||||
"postbox-submit": "Sendu",
|
||||
"num-comments": "{{ n }} komento\n{{ n }} komentoj",
|
||||
"no-comments": "Neniu komento ankoraŭ",
|
||||
|
@ -1,29 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Escriba su comentario aquí (al menos 3 caracteres)",
|
||||
"postbox-author": "Nombre (opcional)",
|
||||
"postbox-email": "E-mail (opcional)",
|
||||
"postbox-website": "Sitio web (opcional)",
|
||||
"postbox-preview": "Vista preliminar",
|
||||
"postbox-edit": "Editar",
|
||||
"postbox-submit": "Enviar",
|
||||
"num-comments": "Un Comentario\n{{ n }} Comentarios",
|
||||
"no-comments": "Sin Comentarios Todavía",
|
||||
"comment-reply": "Responder",
|
||||
"comment-edit": "Editar",
|
||||
"comment-save": "Guardar",
|
||||
"comment-delete": "Eliminar",
|
||||
"comment-confirm": "Confirmar",
|
||||
"comment-close": "Cerrar",
|
||||
"comment-cancel": "Cancelar",
|
||||
"comment-deleted": "Comentario eliminado.",
|
||||
"comment-queued": "Comentario en espera para moderación.",
|
||||
"comment-anonymous": "Anónimo",
|
||||
"comment-hidden": "{{ n }} Oculto(s)",
|
||||
"date-now": "ahora",
|
||||
"date-minute": "hace un minuto\nhace {{ n }} minutos",
|
||||
"date-hour": "hace una hora\nhace {{ n }} horas",
|
||||
"date-day": "ayer\nHace {{ n }} días",
|
||||
"date-week": "la semana pasada\nhace {{ n }} semanas",
|
||||
"date-month": "el mes pasado\nhace {{ n }} meses",
|
||||
"date-year": "el año pasado\nhace {{ n }} años"
|
||||
});
|
@ -1,32 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "نظر خود را اینجا بنویسید (حداقل سه نویسه)",
|
||||
"postbox-author": "اسم (اختیاری)",
|
||||
"postbox-email": "ایمیل (اختیاری)",
|
||||
"postbox-website": "سایت (اختیاری)",
|
||||
"postbox-preview": "پیشنمایش",
|
||||
"postbox-edit": "ویرایش",
|
||||
"postbox-submit": "ارسال",
|
||||
|
||||
"num-comments": "یک نظر\n{{ n }} نظر",
|
||||
"no-comments": "هنوز نظری نوشته نشده است",
|
||||
|
||||
"comment-reply": "پاسخ",
|
||||
"comment-edit": "ویرایش",
|
||||
"comment-save": "ذخیره",
|
||||
"comment-delete": "حذف",
|
||||
"comment-confirm": "تایید",
|
||||
"comment-close": "بستن",
|
||||
"comment-cancel": "انصراف",
|
||||
"comment-deleted": "نظر حذف شد.",
|
||||
"comment-queued": "نظر در صف بررسی مدیر قرار دارد.",
|
||||
"comment-anonymous": "ناشناس",
|
||||
"comment-hidden": "{{ n }} مخفی",
|
||||
|
||||
"date-now": "هم اکنون",
|
||||
"date-minute": "یک دقیقه پیش\n{{ n }} دقیقه پیش",
|
||||
"date-hour": "یک ساعت پیش\n{{ n }} ساعت پیش",
|
||||
"date-day": "دیروز\n{{ n }} روز پیش",
|
||||
"date-week": "یک هفته پیش\n{{ n }} هفته پیش",
|
||||
"date-month": "یک ماه پیش\n{{ n }} ماه پیش",
|
||||
"date-year": "یک سال پیش\n{{ n }} سال پیش"
|
||||
});
|
@ -1,32 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Kirjoita kommentti tähän (vähintään 3 merkkiä)",
|
||||
"postbox-author": "Nimi (valinnainen)",
|
||||
"postbox-email": "Sähköposti (valinnainen)",
|
||||
"postbox-website": "Web-sivu (valinnainen)",
|
||||
"postbox-preview": "Esikatselu",
|
||||
"postbox-edit": "Muokkaa",
|
||||
"postbox-submit": "Lähetä",
|
||||
|
||||
"num-comments": "Yksi kommentti\n{{ n }} kommenttia",
|
||||
"no-comments": "Ei vielä kommentteja",
|
||||
|
||||
"comment-reply": "Vastaa",
|
||||
"comment-edit": "Muokkaa",
|
||||
"comment-save": "Tallenna",
|
||||
"comment-delete": "Poista",
|
||||
"comment-confirm": "Vahvista",
|
||||
"comment-close": "Sulje",
|
||||
"comment-cancel": "Peru",
|
||||
"comment-deleted": "Kommentti on poistettu.",
|
||||
"comment-queued": "Kommentti on laitettu jonoon odottamaan moderointia.",
|
||||
"comment-anonymous": "Nimetön",
|
||||
"comment-hidden": "{{ n }} piilotettua",
|
||||
|
||||
"date-now": "hetki sitten",
|
||||
"date-minute": "minuutti sitten\n{{ n }} minuuttia sitten",
|
||||
"date-hour": "tunti sitten\n{{ n }} tuntia sitten",
|
||||
"date-day": "eilen\n{{ n }} päivää sitten",
|
||||
"date-week": "viime viikolla\n{{ n }} viikkoa sitten",
|
||||
"date-month": "viime kuussa\n{{ n }} kuukautta sitten",
|
||||
"date-year": "viime vuonna\n{{ n }} vuotta sitten"
|
||||
});
|
@ -3,13 +3,14 @@ define({
|
||||
"postbox-author": "Nom (optionnel)",
|
||||
"postbox-email": "Courriel (optionnel)",
|
||||
"postbox-website": "Site web (optionnel)",
|
||||
"postbox-preview": "Aperçu",
|
||||
"postbox-edit": "Éditer",
|
||||
"postbox-submit": "Soumettre",
|
||||
"postbox-notification": "S’abonner aux notifications de réponses",
|
||||
"postbox-auth-required": "Connexion nécessaire pour commenter.",
|
||||
"postbox-auth-sign-in": "Se connecter",
|
||||
"postbox-auth-commenting-as": "Commenter en tant que",
|
||||
|
||||
"num-comments": "{{ n }} commentaire\n{{ n }} commentaires",
|
||||
"no-comments": "Aucun commentaire pour l’instant",
|
||||
"atom-feed": "Flux Atom",
|
||||
"no-comments": "Aucun commentaire pour l'instant",
|
||||
|
||||
"comment-reply": "Répondre",
|
||||
"comment-edit": "Éditer",
|
||||
"comment-save": "Enregistrer",
|
||||
@ -21,7 +22,8 @@ define({
|
||||
"comment-queued": "Commentaire en attente de modération.",
|
||||
"comment-anonymous": "Anonyme",
|
||||
"comment-hidden": "1 caché\n{{ n }} cachés",
|
||||
"date-now": "À l’instant",
|
||||
|
||||
"date-now": "À l'instant'",
|
||||
"date-minute": "Il y a une minute\nIl y a {{ n }} minutes",
|
||||
"date-hour": "Il y a une heure\nIl y a {{ n }} heures ",
|
||||
"date-day": "Hier\nIl y a {{ n }} jours",
|
||||
|
@ -1,29 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Napiši komentar ovdje (najmanje 3 znaka)",
|
||||
"postbox-author": "Ime (neobavezno)",
|
||||
"postbox-email": "E-mail (neobavezno)",
|
||||
"postbox-website": "Web stranica (neobavezno)",
|
||||
"postbox-preview": "Pregled",
|
||||
"postbox-edit": "Uredi",
|
||||
"postbox-submit": "Pošalji",
|
||||
"num-comments": "Jedan komentar\n{{ n }} komentara",
|
||||
"no-comments": "Još nema komentara",
|
||||
"comment-reply": "Odgovori",
|
||||
"comment-edit": "Uredi",
|
||||
"comment-save": "Spremi",
|
||||
"comment-delete": "Obriši",
|
||||
"comment-confirm": "Potvrdi",
|
||||
"comment-close": "Zatvori",
|
||||
"comment-cancel": "Odustani",
|
||||
"comment-deleted": "Komentar obrisan",
|
||||
"comment-queued": "Komentar u redu za provjeru.",
|
||||
"comment-anonymous": "Anonimno",
|
||||
"comment-hidden": "{{ n }} Skrivenih",
|
||||
"date-now": "upravo",
|
||||
"date-minute": "prije minutu\nprije {{ n }} minuta",
|
||||
"date-hour": "prije sat vremena\nprije {{ n }} sati",
|
||||
"date-day": "jučer\nprije {{ n }} dana",
|
||||
"date-week": "prošli tjedan\nprije {{ n }} tjedana",
|
||||
"date-month": "prošli mjesec\nprije {{ n }} mjeseci",
|
||||
"date-year": "prošle godine\nprije {{ n }} godina"
|
||||
});
|
@ -1,29 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Hozzászólást ide írd be (legalább 3 betűt)",
|
||||
"postbox-author": "Név (nem kötelező)",
|
||||
"postbox-email": "Email (nem kötelező)",
|
||||
"postbox-website": "Website (nem kötelező)",
|
||||
"postbox-preview": "Előnézet",
|
||||
"postbox-edit": "Szerekesztés",
|
||||
"postbox-submit": "Elküld",
|
||||
"num-comments": "Egy hozzászólás\n{{ n }} hozzászólás",
|
||||
"no-comments": "Eddig nincs hozzászólás",
|
||||
"comment-reply": "Válasz",
|
||||
"comment-edit": "Szerekesztés",
|
||||
"comment-save": "Mentés",
|
||||
"comment-delete": "Törlés",
|
||||
"comment-confirm": "Megerősít",
|
||||
"comment-close": "Bezár",
|
||||
"comment-cancel": "Törlés",
|
||||
"comment-deleted": "Hozzászólás törölve.",
|
||||
"comment-queued": "A hozzászólást előbb ellenőrizzük.",
|
||||
"comment-anonymous": "Névtelen",
|
||||
"comment-hidden": "{{ n }} rejtve",
|
||||
"date-now": "pillanatokkal ezelőtt",
|
||||
"date-minute": "egy perce\n{{ n }} perce",
|
||||
"date-hour": "egy órája\n{{ n }} órája",
|
||||
"date-day": "tegnap\n{{ n }} napja",
|
||||
"date-week": "múlt héten\n{{ n }} hete",
|
||||
"date-month": "múlt hónapban\n{{ n }} hónapja",
|
||||
"date-year": "tavaly\n{{ n }} éve"
|
||||
});
|
@ -2,9 +2,6 @@ define({
|
||||
"postbox-text": "Scrivi un commento qui (minimo 3 caratteri)",
|
||||
"postbox-author": "Nome (opzionale)",
|
||||
"postbox-email": "E-mail (opzionale)",
|
||||
"postbox-website": "Sito web (opzionale)",
|
||||
"postbox-preview": "Anteprima",
|
||||
"postbox-edit": "Modifica",
|
||||
"postbox-submit": "Invia",
|
||||
"num-comments": "Un Commento\n{{ n }} Commenti",
|
||||
"no-comments": "Ancora Nessun Commento",
|
||||
@ -18,7 +15,6 @@ define({
|
||||
"comment-deleted": "Commento eliminato.",
|
||||
"comment-queued": "Commento in coda per moderazione.",
|
||||
"comment-anonymous": "Anonimo",
|
||||
"comment-hidden": "{{ n }} Nascosto",
|
||||
"date-now": "poco fa",
|
||||
"date-minute": "un minuto fa\n{{ n }} minuti fa",
|
||||
"date-hour": "un ora fa\n{{ n }} ore fa",
|
||||
|
@ -1,29 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Typ reactie hier (minstens 3 karakters)",
|
||||
"postbox-author": "Naam (optioneel)",
|
||||
"postbox-email": "E-mail (optioneel)",
|
||||
"postbox-website": "Website (optioneel)",
|
||||
"postbox-preview": "Voorbeeld",
|
||||
"postbox-edit": "Bewerken",
|
||||
"postbox-submit": "Versturen",
|
||||
"num-comments": "Één reactie\n{{ n }} reacties",
|
||||
"no-comments": "Nog geen reacties",
|
||||
"comment-reply": "Beantwoorden",
|
||||
"comment-edit": "Bewerken",
|
||||
"comment-save": "Opslaan",
|
||||
"comment-delete": "Verwijderen",
|
||||
"comment-confirm": "Bevestigen",
|
||||
"comment-close": "Sluiten",
|
||||
"comment-cancel": "Annuleren",
|
||||
"comment-deleted": "Reactie verwijderd.",
|
||||
"comment-queued": "Reactie staat in de wachtrij voor goedkeuring.",
|
||||
"comment-anonymous": "Anoniem",
|
||||
"comment-hidden": "{{ n }} verborgen",
|
||||
"date-now": "zojuist",
|
||||
"date-minute": "een minuut geleden\n{{ n }} minuten geleden",
|
||||
"date-hour": "een uur geleden\n{{ n }} uur geleden",
|
||||
"date-day": "gisteren\n{{ n }} dagen geleden",
|
||||
"date-week": "vorige week\n{{ n }} weken geleden",
|
||||
"date-month": "vorige maand\n{{ n }} maanden geleden",
|
||||
"date-year": "vorig jaar\n{{ n }} jaar geleden"
|
||||
});
|
@ -1,34 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Escriure lo comentari aquí (almens 3 caractèrs)",
|
||||
"postbox-author": "Nom (opcional)",
|
||||
"postbox-email": "Corrièl (opcional)",
|
||||
"postbox-website": "Site web (opcional)",
|
||||
"postbox-preview": "Apercebut",
|
||||
"postbox-edit": "Modificar",
|
||||
"postbox-submit": "Enviar",
|
||||
"postbox-notification": "S'abonar per corrièl a las notificacions de responsas",
|
||||
|
||||
"num-comments": "Un comentari\n{{ n }} comentaris",
|
||||
"no-comments": "Cap de comentari pel moment",
|
||||
"atom-feed": "Flux Atom",
|
||||
|
||||
"comment-reply": "Respondre",
|
||||
"comment-edit": "Modificar",
|
||||
"comment-save": "Salvar",
|
||||
"comment-delete": "Suprimir",
|
||||
"comment-confirm": "Confirmar",
|
||||
"comment-close": "Tampar",
|
||||
"comment-cancel": "Anullar",
|
||||
"comment-deleted": "Comentari suprimit.",
|
||||
"comment-queued": "Comentari en espèra de moderacion.",
|
||||
"comment-anonymous": "Anonim",
|
||||
"comment-hidden": "1 rescondut\n{{ n }} resconduts",
|
||||
|
||||
"date-now": "ara meteis",
|
||||
"date-minute": "fa una minuta \nfa {{ n }} minutas",
|
||||
"date-hour": "fa una ora\nfa {{ n }} oras",
|
||||
"date-day": "Ièr\nfa {{ n }} jorns",
|
||||
"date-week": "la setmana passada\nfa {{ n }} setmanas",
|
||||
"date-month": "lo mes passat\nfa {{ n }} meses",
|
||||
"date-year": "l'an passat\nfa {{ n }} ans"
|
||||
});
|
@ -1,34 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Tutaj wpisz komentarz (co najmniej 3 znaki)",
|
||||
"postbox-author": "Imię/nick (opcjonalnie)",
|
||||
"postbox-email": "E-mail (opcjonalnie)",
|
||||
"postbox-website": "Strona (opcjonalnie)",
|
||||
"postbox-preview": "Podgląd",
|
||||
"postbox-edit": "Edytuj",
|
||||
"postbox-submit": "Wyślij",
|
||||
"postbox-notification": "Otrzymuj powiadomienia o odpowiedziach na e-mail",
|
||||
|
||||
"num-comments": "Jeden komentarz\n{{ n }} komentarze\n{{ n }} komentarzy",
|
||||
"no-comments": "Nie ma jeszcze komentarzy",
|
||||
"atom-feed": "Kanał Atom",
|
||||
|
||||
"comment-reply": "Odpowiedz",
|
||||
"comment-edit": "Edytuj",
|
||||
"comment-save": "Zapisz",
|
||||
"comment-delete": "Usuń",
|
||||
"comment-confirm": "Potwierdź",
|
||||
"comment-close": "Zamknij",
|
||||
"comment-cancel": "Anuluj",
|
||||
"comment-deleted": "Komentarz usunięty.",
|
||||
"comment-queued": "Komentarz w kolejce do moderacji.",
|
||||
"comment-anonymous": "Anonim",
|
||||
"comment-hidden": "{{ n }} ukryty\n{{ n }} ukryte\n{{ n }} ukrytych",
|
||||
|
||||
"date-now": "teraz",
|
||||
"date-minute": "minutę temu\n{{ n }} minuty temu\n{{ n }} minut temu",
|
||||
"date-hour": "godzinę temu\n{{ n }} godziny temu\n{{ n }} godzin temu",
|
||||
"date-day": "wczoraj\n{{ n }} dni temu",
|
||||
"date-week": "w ubiegłym tygodniu\n{{ n }} tygodnie temu\n{{ n }} tygodni temu",
|
||||
"date-month": "w ubiegłym miesiącu\n{{ n }} miesiące temu\n{{ n }} miesięcy temu",
|
||||
"date-year": "w ubiegłym roku\n{{ n }} lata temu\n{{ n }} lat temu"
|
||||
});
|
@ -1,34 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Digite seu comentário aqui (pelo menos 3 letras)",
|
||||
"postbox-author": "Nome (opcional)",
|
||||
"postbox-email": "E-mail (opcional)",
|
||||
"postbox-website": "Website (opcional)",
|
||||
"postbox-preview": "Prévia",
|
||||
"postbox-edit": "Editar",
|
||||
"postbox-submit": "Enviar",
|
||||
"postbox-notification": "Receber emails de notificação de respostas",
|
||||
|
||||
"num-comments": "Um Comentário\n{{ n }} Comentários",
|
||||
"no-comments": "Nenhum comentário ainda",
|
||||
"atom-feed": "Feed Atom",
|
||||
|
||||
"comment-reply": "Responder",
|
||||
"comment-edit": "Editar",
|
||||
"comment-save": "Salvar",
|
||||
"comment-delete": "Excluir",
|
||||
"comment-confirm": "Confirmar",
|
||||
"comment-close": "Fechar",
|
||||
"comment-cancel": "Cancelar",
|
||||
"comment-deleted": "Comentário apagado.",
|
||||
"comment-queued": "Comentário na fila de moderação.",
|
||||
"comment-anonymous": "Anônimo",
|
||||
"comment-hidden": "{{ n }} Oculto(s)",
|
||||
|
||||
"date-now": "agora mesmo",
|
||||
"date-minute": "um minuto atrás\n{{ n }} minutos atrás",
|
||||
"date-hour": "uma hora atrás\n{{ n }} horas atrás",
|
||||
"date-day": "ontem\n{{ n }} dias",
|
||||
"date-week": "semana passada\n{{ n }} semanas atrás",
|
||||
"date-month": "mês passado\n{{ n }} meses atrás",
|
||||
"date-year": "ano passado\n{{ n }} anos atrás"
|
||||
});
|
@ -1,30 +1,25 @@
|
||||
define({
|
||||
"postbox-text": "Оставить комментарий (минимум 3 символа)",
|
||||
"postbox-text": "Комментировать здесь (миниум 3 символа)",
|
||||
"postbox-author": "Имя (необязательно)",
|
||||
"postbox-email": "Email (необязательно)",
|
||||
"postbox-website": "Сайт (необязательно)",
|
||||
"postbox-preview": "Предпросмотр",
|
||||
"postbox-edit": "Правка",
|
||||
"postbox-submit": "Отправить",
|
||||
"postbox-notification": "Подписаться на уведомление об ответах",
|
||||
"num-comments": "{{ n }} комментарий\n{{ n }} комментария\n{{ n }} комментариев",
|
||||
"no-comments": "Пока нет комментариев",
|
||||
"num-comments": "1 Комментарий\n{{ n }} Комментарии",
|
||||
"no-comments": "Нет Комментарев",
|
||||
"comment-reply": "Ответить",
|
||||
"comment-edit": "Правка",
|
||||
"comment-save": "Сохранить",
|
||||
"comment-delete": "Удалить",
|
||||
"comment-confirm": "Подтвердить удаление",
|
||||
"comment-confirm": "Подтвердить",
|
||||
"comment-close": "Закрыть",
|
||||
"comment-cancel": "Отменить",
|
||||
"comment-deleted": "Комментарий удалён",
|
||||
"comment-queued": "Комментарий будет проверен модератором",
|
||||
"comment-anonymous": "Аноним",
|
||||
"comment-hidden": "Скрыт {{ n }} комментарий\nСкрыто {{ n }} комментария\nСкрыто {{ n }} комментариев",
|
||||
"date-now": "Только что",
|
||||
"date-minute": "{{ n }} минуту назад\n{{ n }} минуты назад\n{{ n }} минут назад",
|
||||
"date-hour": "{{ n }} час назад\n{{ n }} часа назад\n{{ n }} часов назад",
|
||||
"date-day": "{{ n }} день назад\n{{ n }} дня назад\n{{ n }} дней назад",
|
||||
"date-week": "{{ n }} неделю назад\n{{ n }} недели назад\n{{ n }} недель назад",
|
||||
"date-month": "{{ n }} месяц назад\n{{ n }} месяца назад\n{{ n }} месяцев назад",
|
||||
"date-year": "{{ n }} год назад\n{{ n }} года назад\n{{ n }} лет назад"
|
||||
"comment-deleted": "Удалить комментарий",
|
||||
"comment-queued": "Комментарий должен быть разблокирован",
|
||||
"comment-anonymous": "Анонимный",
|
||||
"date-now": "Сейчас",
|
||||
"date-minute": "Минут назад\n{{ n }} минут",
|
||||
"date-hour": "Час назад\n{{ n }} часов",
|
||||
"date-day": "Вчера\n{{ n }} дней",
|
||||
"date-week": "на прошлой недели\n{{ n }} недель",
|
||||
"date-month": "в прошоим месяце\n{{ n }} месяцов",
|
||||
"date-year": "в прошлом году\n{{ n }} года\n{{ n }} лет"
|
||||
});
|
||||
|
@ -1,29 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Sem napíšte svoj komentár (minimálne 3 znaky)",
|
||||
"postbox-author": "Meno (nepovinné)",
|
||||
"postbox-email": "E-mail (nepovinný)",
|
||||
"postbox-website": "Web (nepovinný)",
|
||||
"postbox-preview": "Náhľad",
|
||||
"postbox-edit": "Upraviť",
|
||||
"postbox-submit": "Publikovať",
|
||||
"num-comments": "Jeden komentár\n{{ n }} komentáre\n{{ n }} komentárov",
|
||||
"no-comments": "Zatiaľ bez komentárov",
|
||||
"comment-reply": "Odpovedať",
|
||||
"comment-edit": "Upraviť",
|
||||
"comment-save": "Uložiť",
|
||||
"comment-delete": "Zmazať",
|
||||
"comment-confirm": "Potvrdit",
|
||||
"comment-close": "Zavrieť",
|
||||
"comment-cancel": "Zrušiť",
|
||||
"comment-deleted": "Komentár bol vymazaný",
|
||||
"comment-queued": "Komentár zaradený na schválenie",
|
||||
"comment-anonymous": "Anonym",
|
||||
"comment-hidden": "{{ n }} skrytý\n{{ n }} skryté\n{{ n }} skrytých",
|
||||
"date-now": "práve teraz",
|
||||
"date-minute": "pred minútou\npred {{ n }} minútami",
|
||||
"date-hour": "pred hodinou\npred {{ n }} hodinami",
|
||||
"date-day": "včera\npred {{ n }} dňami",
|
||||
"date-week": "minulý týždeň\npred {{ n }} týždňami",
|
||||
"date-month": "minulý mesiac\npred {{ n }} mesiacmi",
|
||||
"date-year": "minulý rok\npred {{ n }} rokmi"
|
||||
});
|
@ -1,29 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Skriv din kommentar här (minst 3 tecken)",
|
||||
"postbox-author": "Namn (frivilligt)",
|
||||
"postbox-email": "E-mail (frivilligt)",
|
||||
"postbox-website": "Hemsida (frivilligt)",
|
||||
"postbox-preview": "Förhandsvisning",
|
||||
"postbox-edit": "Redigera",
|
||||
"postbox-submit": "Skicka",
|
||||
"num-comments": "En kommentar\n{{ n }} kommentarer",
|
||||
"no-comments": "Inga kommentarer än",
|
||||
"comment-reply": "Svara",
|
||||
"comment-edit": "Redigera",
|
||||
"comment-save": "Spara",
|
||||
"comment-delete": "Radera",
|
||||
"comment-confirm": "Bekräfta",
|
||||
"comment-close": "Stäng",
|
||||
"comment-cancel": "Avbryt",
|
||||
"comment-deleted": "Kommentar raderad.",
|
||||
"comment-queued": "Kommentaren inväntar granskning.",
|
||||
"comment-anonymous": "Anonym",
|
||||
"comment-hidden": "{{ n }} Gömd",
|
||||
"date-now": "just nu",
|
||||
"date-minute": "en minut sedan\n{{ n }} minuter sedan",
|
||||
"date-hour": "en timme sedan\n{{ n }} timmar sedan",
|
||||
"date-day": "igår\n{{ n }} dagar sedan",
|
||||
"date-week": "förra veckan\n{{ n }} veckor sedan",
|
||||
"date-month": "förra månaden\n{{ n }} månader sedan",
|
||||
"date-year": "förra året\n{{ n }} år sedan"
|
||||
});
|
@ -1,32 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "Nhập bình luận tại đây (tối thiểu 3 ký tự)",
|
||||
"postbox-author": "Tên (tùy chọn)",
|
||||
"postbox-email": "E-mail (tùy chọn)",
|
||||
"postbox-website": "Website (tùy chọn)",
|
||||
"postbox-preview": "Xem trước",
|
||||
"postbox-edit": "Sửa",
|
||||
"postbox-submit": "Gửi",
|
||||
|
||||
"num-comments": "Một bình luận\n{{ n }} bình luận",
|
||||
"no-comments": "Chưa có bình luận nào",
|
||||
|
||||
"comment-reply": "Trả lời",
|
||||
"comment-edit": "Sửa",
|
||||
"comment-save": "Lưu",
|
||||
"comment-delete": "Xóa",
|
||||
"comment-confirm": "Xác nhận",
|
||||
"comment-close": "Đóng",
|
||||
"comment-cancel": "Hủy",
|
||||
"comment-deleted": "Đã xóa bình luận.",
|
||||
"comment-queued": "Bình luận đang chờ duyệt",
|
||||
"comment-anonymous": "Nặc danh",
|
||||
"comment-hidden": "{{ n }} đã ẩn",
|
||||
|
||||
"date-now": "vừa mới",
|
||||
"date-minute": "một phút trước\n{{ n }} phút trước",
|
||||
"date-hour": "một giờ trước\n{{ n }} giờ trước",
|
||||
"date-day": "Hôm qua\n{{ n }} ngày trước",
|
||||
"date-week": "Tuần qua\n{{ n }} tuần trước",
|
||||
"date-month": "Tháng trước\n{{ n }} tháng trước",
|
||||
"date-year": "Năm trước\n{{ n }} năm trước"
|
||||
});
|
@ -1,33 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "在此输入评论 (最少 3 个字符)",
|
||||
"postbox-author": "名字 (可选)",
|
||||
"postbox-email": "电子邮箱 (可选)",
|
||||
"postbox-website": "网站 (可选)",
|
||||
"postbox-preview": "预览",
|
||||
"postbox-edit": "编辑",
|
||||
"postbox-submit": "提交",
|
||||
"postbox-notification": "有新回复时发送邮件通知",
|
||||
|
||||
"num-comments": "1 条评论\n{{ n }} 条评论",
|
||||
"no-comments": "还没有评论",
|
||||
|
||||
"comment-reply": "回复",
|
||||
"comment-edit": "编辑",
|
||||
"comment-save": "保存",
|
||||
"comment-delete": "删除",
|
||||
"comment-confirm": "确认",
|
||||
"comment-close": "关闭",
|
||||
"comment-cancel": "取消",
|
||||
"comment-deleted": "评论已删除.",
|
||||
"comment-queued": "评论待审核.",
|
||||
"comment-anonymous": "匿名",
|
||||
"comment-hidden": "{{ n }} 条评论已隐藏",
|
||||
|
||||
"date-now": "刚刚",
|
||||
"date-minute": "1 分钟前\n{{ n }} 分钟前",
|
||||
"date-hour": "1 小时前\n{{ n }} 小时前",
|
||||
"date-day": "昨天\n{{ n }} 天前",
|
||||
"date-week": "上周\n{{ n }} 周前",
|
||||
"date-month": "上个月\n{{ n }} 个月前",
|
||||
"date-year": "去年\n{{ n }} 年前"
|
||||
});
|
@ -1,33 +0,0 @@
|
||||
define({
|
||||
"postbox-text": "在此輸入留言 (至少 3 個字元)",
|
||||
"postbox-author": "名稱 (非必填)",
|
||||
"postbox-email": "電子信箱 (非必填)",
|
||||
"postbox-website": "個人網站 (非必填)",
|
||||
"postbox-preview": "預覽",
|
||||
"postbox-edit": "編輯",
|
||||
"postbox-submit": "送出",
|
||||
"postbox-notification": "訂閱回复的電子郵件通知",
|
||||
|
||||
"num-comments": "1 則留言\n{{ n }} 則留言",
|
||||
"no-comments": "尚無留言",
|
||||
|
||||
"comment-reply": "回覆",
|
||||
"comment-edit": "編輯",
|
||||
"comment-save": "儲存",
|
||||
"comment-delete": "刪除",
|
||||
"comment-confirm": "確認",
|
||||
"comment-close": "關閉",
|
||||
"comment-cancel": "取消",
|
||||
"comment-deleted": "留言已刪",
|
||||
"comment-queued": "留言待審",
|
||||
"comment-anonymous": "匿名",
|
||||
"comment-hidden": "{{ n }} 則隱藏留言",
|
||||
|
||||
"date-now": "剛剛",
|
||||
"date-minute": "1 分鐘前\n{{ n }} 分鐘前",
|
||||
"date-hour": "1 小時前\n{{ n }} 小時前",
|
||||
"date-day": "昨天\n{{ n }} 天前",
|
||||
"date-week": "上週\n{{ n }} 週前",
|
||||
"date-month": "上個月\n{{ n }} 個月前",
|
||||
"date-year": "去年\n{{ n }} 年前"
|
||||
});
|
@ -7,13 +7,21 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
|
||||
|
||||
var Postbox = function(parent) {
|
||||
|
||||
var localStorage = utils.localStorageImpl,
|
||||
el = $.htmlify(jade.render("postbox", {
|
||||
"author": JSON.parse(localStorage.getItem("author")),
|
||||
"email": JSON.parse(localStorage.getItem("email")),
|
||||
"website": JSON.parse(localStorage.getItem("website")),
|
||||
"preview": ''
|
||||
}));
|
||||
if (config['auth'] === true) {
|
||||
var authCookie = utils.cookie('auth');
|
||||
if (! authCookie) {
|
||||
jade.set("cookie", {'valid': false});
|
||||
} else {
|
||||
var authData = lib.itsdangerous(authCookie)
|
||||
if (! authData) {
|
||||
jade.set("cookie", {'valid': false});
|
||||
} else {
|
||||
jade.set("cookie", {'valid': true, 'data': authData});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var el = $.htmlify(jade.render("postbox"));
|
||||
|
||||
// callback on success (e.g. to toggle the reply button)
|
||||
el.onsuccess = function() {};
|
||||
@ -25,84 +33,30 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
|
||||
$(".textarea", this).focus();
|
||||
return false;
|
||||
}
|
||||
if (config["require-email"] &&
|
||||
$("[name='email']", this).value.length <= 0)
|
||||
{
|
||||
$("[name='email']", this).focus();
|
||||
return false;
|
||||
}
|
||||
if (config["require-author"] &&
|
||||
$("[name='author']", this).value.length <= 0)
|
||||
{
|
||||
$("[name='author']", this).focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// only display notification checkbox if email is filled in
|
||||
var email_edit = function() {
|
||||
if (config["reply-notifications"] && $("[name='email']", el).value.length > 0) {
|
||||
$(".notification-section", el).show();
|
||||
} else {
|
||||
$(".notification-section", el).hide();
|
||||
}
|
||||
};
|
||||
$("[name='email']", el).on("input", email_edit);
|
||||
email_edit();
|
||||
|
||||
// email is not optional if this config parameter is set
|
||||
if (config["require-email"]) {
|
||||
$("[name='email']", el).setAttribute("placeholder",
|
||||
$("[name='email']", el).getAttribute("placeholder").replace(/ \(.*\)/, ""));
|
||||
}
|
||||
|
||||
// author is not optional if this config parameter is set
|
||||
if (config["require-author"]) {
|
||||
$("[name='author']", el).placeholder =
|
||||
$("[name='author']", el).placeholder.replace(/ \(.*\)/, "");
|
||||
}
|
||||
|
||||
// preview function
|
||||
$("[name='preview']", el).on("click", function() {
|
||||
api.preview(utils.text($(".textarea", el).innerHTML)).then(
|
||||
function(html) {
|
||||
$(".preview .text", el).innerHTML = html;
|
||||
el.classList.add('preview-mode');
|
||||
});
|
||||
});
|
||||
|
||||
// edit function
|
||||
var edit = function() {
|
||||
$(".preview .text", el).innerHTML = '';
|
||||
el.classList.remove('preview-mode');
|
||||
};
|
||||
$("[name='edit']", el).on("click", edit);
|
||||
$(".preview", el).on("click", edit);
|
||||
|
||||
// submit form, initialize optional fields with `null` and reset form.
|
||||
// If replied to a comment, remove form completely.
|
||||
$("[type=submit]", el).on("click", function() {
|
||||
edit();
|
||||
var submit = $("[type=submit]", el)
|
||||
if (submit === null) {
|
||||
return el;
|
||||
}
|
||||
submit.on("click", function() {
|
||||
if (! el.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var author = $("[name=author]", el).value || null,
|
||||
email = $("[name=email]", el).value || null,
|
||||
website = $("[name=website]", el).value || null;
|
||||
|
||||
localStorage.setItem("author", JSON.stringify(author));
|
||||
localStorage.setItem("email", JSON.stringify(email));
|
||||
localStorage.setItem("website", JSON.stringify(website));
|
||||
|
||||
api.create($("#isso-thread").getAttribute("data-isso-id"), {
|
||||
author: author, email: email, website: website,
|
||||
author: $("[name=author]", el).value || null,
|
||||
email: $("[name=email]", el).value || null,
|
||||
website: $("[name=website]", el).value || null,
|
||||
text: utils.text($(".textarea", el).innerHTML),
|
||||
parent: parent || null,
|
||||
title: $("#isso-thread").getAttribute("data-title") || null,
|
||||
notification: $("[name=notification]", el).checked() ? 1 : 0,
|
||||
parent: parent || null
|
||||
}).then(function(comment) {
|
||||
$("[name=author]", el).value = "";
|
||||
$("[name=email]", el).value = "";
|
||||
$("[name=website]", el).value = "";
|
||||
$(".textarea", el).innerHTML = "";
|
||||
$(".textarea", el).blur();
|
||||
insert(comment, true);
|
||||
@ -165,7 +119,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
|
||||
|
||||
// update datetime every 60 seconds
|
||||
var refresh = function() {
|
||||
$(".permalink > time", el).textContent = utils.ago(
|
||||
$(".permalink > date", el).textContent = utils.ago(
|
||||
globals.offset.localTime(), new Date(parseInt(comment.created, 10) * 1000));
|
||||
setTimeout(refresh, 60*1000);
|
||||
};
|
||||
@ -208,58 +162,36 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
|
||||
}
|
||||
);
|
||||
|
||||
if (config.vote) {
|
||||
var voteLevels = config['vote-levels'];
|
||||
if (typeof voteLevels === 'string') {
|
||||
// Eg. -5,5,15
|
||||
voteLevels = voteLevels.split(',');
|
||||
}
|
||||
|
||||
// update vote counter
|
||||
var votes = function (value) {
|
||||
var span = $("span.votes", footer);
|
||||
if (span === null) {
|
||||
footer.prepend($.new("span.votes", value));
|
||||
// update vote counter, but hide if votes sum to 0
|
||||
var votes = function(value) {
|
||||
var span = $("span.votes", footer);
|
||||
if (span === null && value !== 0) {
|
||||
footer.prepend($.new("span.votes", value));
|
||||
} else {
|
||||
if (value === 0) {
|
||||
span.remove();
|
||||
} else {
|
||||
span.textContent = value;
|
||||
}
|
||||
if (value) {
|
||||
el.classList.remove('isso-no-votes');
|
||||
} else {
|
||||
el.classList.add('isso-no-votes');
|
||||
}
|
||||
if (voteLevels) {
|
||||
var before = true;
|
||||
for (var index = 0; index <= voteLevels.length; index++) {
|
||||
if (before && (index >= voteLevels.length || value < voteLevels[index])) {
|
||||
el.classList.add('isso-vote-level-' + index);
|
||||
before = false;
|
||||
} else {
|
||||
el.classList.remove('isso-vote-level-' + index);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
$("a.upvote", footer).on("click", function () {
|
||||
api.like(comment.id).then(function (rv) {
|
||||
votes(rv.likes - rv.dislikes);
|
||||
});
|
||||
$("a.upvote", footer).on("click", function() {
|
||||
api.like(comment.id).then(function(rv) {
|
||||
votes(rv.likes - rv.dislikes);
|
||||
});
|
||||
});
|
||||
|
||||
$("a.downvote", footer).on("click", function () {
|
||||
api.dislike(comment.id).then(function (rv) {
|
||||
votes(rv.likes - rv.dislikes);
|
||||
});
|
||||
$("a.downvote", footer).on("click", function() {
|
||||
api.dislike(comment.id).then(function(rv) {
|
||||
votes(rv.likes - rv.dislikes);
|
||||
});
|
||||
|
||||
votes(comment.likes - comment.dislikes);
|
||||
}
|
||||
});
|
||||
|
||||
$("a.edit", footer).toggle("click",
|
||||
function(toggler) {
|
||||
var edit = $("a.edit", footer);
|
||||
var avatar = config["avatar"] || config["gravatar"] ? $(".avatar", el, false)[0] : null;
|
||||
var avatar = $(".avatar", el, false)[0];
|
||||
|
||||
edit.textContent = i18n.translate("comment-save");
|
||||
edit.insertAfter($.new("a.cancel", i18n.translate("comment-cancel"))).on("click", function() {
|
||||
@ -287,7 +219,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
|
||||
},
|
||||
function(toggler) {
|
||||
var textarea = $(".textarea", text);
|
||||
var avatar = config["avatar"] || config["gravatar"] ? $(".avatar", el, false)[0] : null;
|
||||
var avatar = $(".avatar", el, false)[0];
|
||||
|
||||
if (! toggler.canceled && textarea !== null) {
|
||||
if (utils.text(textarea.innerHTML).length < 3) {
|
||||
|
@ -7,9 +7,6 @@ define(["libjs-jade-runtime", "app/utils", "jade!app/text/postbox", "jade!app/te
|
||||
var load = function(name, js) {
|
||||
templates[name] = (function(jade) {
|
||||
var fn;
|
||||
if (js.compiled) {
|
||||
return js(jade);
|
||||
}
|
||||
eval("fn = " + js);
|
||||
return fn;
|
||||
})(runtime);
|
||||
@ -24,13 +21,6 @@ define(["libjs-jade-runtime", "app/utils", "jade!app/text/postbox", "jade!app/te
|
||||
load("comment-loader", tt_comment_loader);
|
||||
|
||||
set("bool", function(arg) { return arg ? true : false; });
|
||||
set("humanize", function(date) {
|
||||
if (typeof date !== "object") {
|
||||
date = new Date(parseInt(date, 10) * 1000);
|
||||
}
|
||||
|
||||
return date.toString();
|
||||
});
|
||||
set("datetime", function(date) {
|
||||
if (typeof date !== "object") {
|
||||
date = new Date(parseInt(date, 10) * 1000);
|
||||
@ -40,11 +30,7 @@ define(["libjs-jade-runtime", "app/utils", "jade!app/text/postbox", "jade!app/te
|
||||
date.getUTCFullYear(),
|
||||
utils.pad(date.getUTCMonth(), 2),
|
||||
utils.pad(date.getUTCDay(), 2)
|
||||
].join("-") + "T" + [
|
||||
utils.pad(date.getUTCHours(), 2),
|
||||
utils.pad(date.getUTCMinutes(), 2),
|
||||
utils.pad(date.getUTCSeconds(), 2)
|
||||
].join(":") + "Z";
|
||||
].join("-");
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -1,6 +1,7 @@
|
||||
define(function (require) {
|
||||
return {
|
||||
editorify: require("app/lib/editor"),
|
||||
identicons: require("app/lib/identicons")
|
||||
identicons: require("app/lib/identicons"),
|
||||
itsdangerous: require("app/lib/itsdangerous")
|
||||
};
|
||||
});
|
||||
|
@ -3,7 +3,6 @@ define(["app/dom", "app/i18n"], function($, i18n) {
|
||||
"use strict";
|
||||
|
||||
return function(el) {
|
||||
el = $.htmlify(el);
|
||||
el.setAttribute("contentEditable", true);
|
||||
|
||||
el.on("focus", function() {
|
||||
@ -22,4 +21,5 @@ define(["app/dom", "app/i18n"], function($, i18n) {
|
||||
|
||||
return el;
|
||||
};
|
||||
});
|
||||
|
||||
});
|
@ -47,7 +47,7 @@ define(["app/lib/promise", "app/config"], function(Q, config) {
|
||||
}
|
||||
|
||||
Q.when(key, function(key) {
|
||||
var hash = pad((parseInt(key.substr(-16), 16) % Math.pow(2, 18)).toString(2), 18),
|
||||
var hash = pad((parseInt(key, 16) % Math.pow(2, 18)).toString(2), 18),
|
||||
index = 0;
|
||||
|
||||
svg.setAttribute("data-hash", key);
|
||||
|
47
isso/js/app/lib/itsdangerous.js
Normal file
47
isso/js/app/lib/itsdangerous.js
Normal file
@ -0,0 +1,47 @@
|
||||
define(function() {
|
||||
"use strict";
|
||||
|
||||
// 2011/01/01 in UTC
|
||||
var epoch = 1293840000;
|
||||
|
||||
var decode = function(str) {
|
||||
return atob(str + new Array((((- str.length) % 4 + 4) % 4) + 1).join("="));
|
||||
};
|
||||
|
||||
var timestamp = function(str) {
|
||||
var bytes = [];
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
bytes.push(str.charCodeAt(i));
|
||||
}
|
||||
|
||||
var a = 0;
|
||||
for (var j = 0; j < bytes.length; j++) {
|
||||
a = a << 8 | +bytes[j];
|
||||
}
|
||||
|
||||
return a + epoch;
|
||||
};
|
||||
|
||||
/*
|
||||
* Load data signed with itsdangerous' URLSafeTimedSerializer.
|
||||
*
|
||||
* If no signature was found or the payload has been expired, return
|
||||
* `null`. Otherwise, return unserialized datastructure.
|
||||
*/
|
||||
return function(val, max_age) {
|
||||
|
||||
var _ = val.split(".", 3),
|
||||
payload = _[0], ts = _[1], signature = _[2];
|
||||
|
||||
if (typeof signature === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
var age = (new Date()).getTime() / 1000 - timestamp(decode(ts));
|
||||
if (typeof max_age !== "undefined" && age > max_age) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(decode(payload));
|
||||
};
|
||||
});
|
50
isso/js/app/markup.js
Normal file
50
isso/js/app/markup.js
Normal file
@ -0,0 +1,50 @@
|
||||
define(["vendor/markup", "app/config", "app/i18n", "app/text/svg"], function(Mark, config, i18n, svg) {
|
||||
|
||||
"use strict";
|
||||
|
||||
|
||||
// circumvent https://github.com/adammark/Markup.js/issues/22
|
||||
function merge(obj) {
|
||||
var result = {};
|
||||
for (var prefix in obj) {
|
||||
for (var attrname in obj[prefix]) {
|
||||
result[prefix + "-" + attrname] = obj[prefix][attrname];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Mark.delimiter = ":";
|
||||
Mark.includes = merge({
|
||||
conf: config,
|
||||
i18n: i18n[i18n.lang],
|
||||
svg: svg
|
||||
});
|
||||
|
||||
Mark.pipes.datetime = function(date) {
|
||||
if (typeof date !== "object") {
|
||||
date = new Date(parseInt(date, 10) * 1000);
|
||||
}
|
||||
|
||||
return [date.getUTCFullYear(), pad(date.getUTCMonth(), 2), pad(date.getUTCDay(), 2)].join("-");
|
||||
};
|
||||
|
||||
Mark.pipes.substract = function(a, b) {
|
||||
return parseInt(a, 10) - parseInt(b, 10);
|
||||
};
|
||||
|
||||
var strip = function(string) {
|
||||
// allow whitespace between Markup.js delimiters such as
|
||||
// {{ var | pipe : arg }} instead of {{var|pipe:arg}}
|
||||
return string.replace(/\{\{\s*(.+?)\s*\}\}/g, function(match, val) {
|
||||
return ("{{" + val + "}}").replace(/\s*\|\s*/g, "|")
|
||||
.replace(/\s*\:\s*/g, ":");
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
up: function(template, context) {
|
||||
return Mark.up(strip(template), context);
|
||||
}
|
||||
};
|
||||
});
|
@ -1,7 +1,4 @@
|
||||
div(class='isso-comment' id='isso-#{comment.id}')
|
||||
if conf.gravatar
|
||||
div(class='avatar')
|
||||
img(src='#{comment.gravatar_image}')
|
||||
if conf.avatar
|
||||
div(class='avatar')
|
||||
svg(data-hash='#{comment.hash}')
|
||||
@ -13,9 +10,9 @@ div(class='isso-comment' id='isso-#{comment.id}')
|
||||
else
|
||||
span(class='author')
|
||||
= bool(comment.author) ? comment.author : i18n('comment-anonymous')
|
||||
span(class="spacer") •
|
||||
span(class="spacer") •
|
||||
a(class='permalink' href='#isso-#{comment.id}')
|
||||
time(title='#{humanize(comment.created)}' datetime='#{datetime(comment.created)}')
|
||||
date(datetime='#{datetime(comment.created)}')
|
||||
span(class='note')
|
||||
= comment.mode == 2 ? i18n('comment-queued') : comment.mode == 4 ? i18n('comment-deleted') : ''
|
||||
|
||||
@ -26,12 +23,13 @@ div(class='isso-comment' id='isso-#{comment.id}')
|
||||
!= comment.text
|
||||
|
||||
div(class='isso-comment-footer')
|
||||
if conf.vote
|
||||
a(class='upvote' href='#')
|
||||
!= svg['arrow-up']
|
||||
span(class='spacer') |
|
||||
a(class='downvote' href='#')
|
||||
!= svg['arrow-down']
|
||||
if comment.likes - comment.dislikes != 0
|
||||
span(class='votes') #{comment.likes - comment.dislikes}
|
||||
a(class='upvote' href='#')
|
||||
i!= svg['arrow-up']
|
||||
span(class='spacer') |
|
||||
a(class='downvote' href='#')
|
||||
i!= svg['arrow-down']
|
||||
a(class='reply' href='#') #{i18n('comment-reply')}
|
||||
a(class='edit' href='#') #{i18n('comment-edit')}
|
||||
a(class='delete' href='#') #{i18n('comment-delete')}
|
||||
|
@ -1,31 +1,25 @@
|
||||
div(class='isso-postbox')
|
||||
mixin form(style)
|
||||
div(class='form-wrapper')
|
||||
div(class='textarea-wrapper')
|
||||
div(class='textarea placeholder' contenteditable='true')
|
||||
= i18n('postbox-text')
|
||||
div(class='preview')
|
||||
div(class='isso-comment')
|
||||
div(class='text-wrapper')
|
||||
div(class='text')
|
||||
section(class='auth-section')
|
||||
p(class='input-wrapper')
|
||||
input(type='text' name='author' placeholder=i18n('postbox-author')
|
||||
value=author !== null ? '#{author}' : '')
|
||||
p(class='input-wrapper')
|
||||
input(type='email' name='email' placeholder=i18n('postbox-email')
|
||||
value=email != null ? '#{email}' : '')
|
||||
p(class='input-wrapper')
|
||||
input(type='text' name='website' placeholder=i18n('postbox-website')
|
||||
value=website != null ? '#{website}' : '')
|
||||
p(class='input-wrapper' style=style)
|
||||
input(type='text' name='author' placeholder=i18n('postbox-author'))
|
||||
p(class='input-wrapper' style=style)
|
||||
input(type='email' name='email' placeholder=i18n('postbox-email'))
|
||||
p(class='input-wrapper' style=style)
|
||||
input(type='text' name='website' placeholder=i18n('postbox-website'))
|
||||
p(class='post-action')
|
||||
input(type='submit' value=i18n('postbox-submit'))
|
||||
p(class='post-action')
|
||||
input(type='button' name='preview'
|
||||
value=i18n('postbox-preview'))
|
||||
p(class='post-action')
|
||||
input(type='button' name='edit'
|
||||
value=i18n('postbox-edit'))
|
||||
section(class='notification-section')
|
||||
label
|
||||
input(type='checkbox' name='notification')
|
||||
= i18n('postbox-notification')
|
||||
|
||||
div(class='isso-postbox')
|
||||
if conf.auth
|
||||
if cookie.valid
|
||||
p=i18n('postbox-auth-commenting-as') + ' ' + cookie.data.username
|
||||
+form('visibility: hidden;')
|
||||
else
|
||||
p=i18n('postbox-auth-required')
|
||||
a(href='#{conf["auth-sign-in-url"]}') #{i18n('postbox-auth-sign-in')}
|
||||
else
|
||||
+form('')
|
||||
|
@ -20,8 +20,8 @@ define(["app/i18n"], function(i18n) {
|
||||
secs = 0;
|
||||
}
|
||||
|
||||
var mins = Math.floor(secs / 60), hours = Math.floor(mins / 60),
|
||||
days = Math.floor(hours / 24);
|
||||
var mins = Math.ceil(secs / 60), hours = Math.ceil(mins / 60),
|
||||
days = Math.ceil(hours / 24);
|
||||
|
||||
return secs <= 45 && i18n.translate("date-now") ||
|
||||
secs <= 90 && i18n.pluralize("date-minute", 1) ||
|
||||
@ -31,11 +31,11 @@ define(["app/i18n"], function(i18n) {
|
||||
hours <= 36 && i18n.pluralize("date-day", 1) ||
|
||||
days <= 5 && i18n.pluralize("date-day", days) ||
|
||||
days <= 8 && i18n.pluralize("date-week", 1) ||
|
||||
days <= 21 && i18n.pluralize("date-week", Math.floor(days / 7)) ||
|
||||
days <= 21 && i18n.pluralize("date-week", Math.ceil(days / 7)) ||
|
||||
days <= 45 && i18n.pluralize("date-month", 1) ||
|
||||
days <= 345 && i18n.pluralize("date-month", Math.floor(days / 30)) ||
|
||||
days <= 345 && i18n.pluralize("date-month", Math.ceil(days / 30)) ||
|
||||
days <= 547 && i18n.pluralize("date-year", 1) ||
|
||||
i18n.pluralize("date-year", Math.floor(days / 365.25));
|
||||
i18n.pluralize("date-year", Math.ceil(days / 365.25));
|
||||
};
|
||||
|
||||
var HTMLEntity = {
|
||||
@ -57,45 +57,20 @@ define(["app/i18n"], function(i18n) {
|
||||
var _ = document.createElement("div");
|
||||
_.innerHTML = html.replace(/<div><br><\/div>/gi, '<br>')
|
||||
.replace(/<div>/gi,'<br>')
|
||||
.replace(/<br>/gi, '\n')
|
||||
.replace(/ /gi, ' ');
|
||||
.replace(/<br>/gi, '\n');
|
||||
return _.textContent.trim();
|
||||
};
|
||||
|
||||
var detext = function(text) {
|
||||
text = escape(text);
|
||||
return text.replace(/\n\n/gi, '<br><div><br></div>')
|
||||
.replace(/\n/gi, '<br>');
|
||||
return escape(text.replace(/\n\n/gi, '<br><div><br></div>')
|
||||
.replace(/\n/gi, '<br>'));
|
||||
};
|
||||
|
||||
// Safari private browsing mode supports localStorage, but throws QUOTA_EXCEEDED_ERR
|
||||
var localStorageImpl;
|
||||
try {
|
||||
localStorage.setItem("x", "y");
|
||||
localStorage.removeItem("x");
|
||||
localStorageImpl = localStorage;
|
||||
} catch (ex) {
|
||||
localStorageImpl = (function(storage) {
|
||||
return {
|
||||
setItem: function(key, val) {
|
||||
storage[key] = val;
|
||||
},
|
||||
getItem: function(key) {
|
||||
return typeof(storage[key]) !== 'undefined' ? storage[key] : null;
|
||||
},
|
||||
removeItem: function(key) {
|
||||
delete storage[key];
|
||||
}
|
||||
};
|
||||
})({});
|
||||
}
|
||||
|
||||
return {
|
||||
cookie: cookie,
|
||||
pad: pad,
|
||||
ago: ago,
|
||||
text: text,
|
||||
detext: detext,
|
||||
localStorageImpl: localStorageImpl
|
||||
detext: detext
|
||||
};
|
||||
});
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user