Compare commits
39 Commits
Author | SHA1 | Date |
---|---|---|
Martin Zimmermann | 01d86881ca | 10 years ago |
Martin Zimmermann | e2e69c4124 | 10 years ago |
Martin Zimmermann | 3809f49f98 | 10 years ago |
Martin Zimmermann | 49f0031157 | 10 years ago |
Martin Zimmermann | 2a0898c928 | 10 years ago |
Martin Zimmermann | cffe8cea08 | 10 years ago |
Martin Zimmermann | c9333adc5c | 10 years ago |
Martin Zimmermann | 08cb3cd324 | 10 years ago |
Martin Zimmermann | e706fabb26 | 10 years ago |
Martin Zimmermann | a0a2662cc9 | 10 years ago |
Martin Zimmermann | 8d2b4b4584 | 10 years ago |
Martin Zimmermann | c9ff66e172 | 10 years ago |
Martin Zimmermann | 95dba92d46 | 10 years ago |
Martin Zimmermann | db9bfddc13 | 10 years ago |
Martin Zimmermann | 6245a8dc17 | 10 years ago |
Martin Zimmermann | bd1cb498d1 | 10 years ago |
Martin Zimmermann | fc2cc0c65f | 10 years ago |
Martin Zimmermann | ff272f60ce | 10 years ago |
Martin Zimmermann | 0365b7057a | 10 years ago |
Nicolas Le Manchet | 7174c6a686 | 10 years ago |
Martin Zimmermann | b2f925f99b | 10 years ago |
Nicolas Le Manchet | 43d8ae702d | 10 years ago |
Martin Zimmermann | 9aa1e9544d | 10 years ago |
Martin Zimmermann | 131951c976 | 10 years ago |
Martin Zimmermann | 7a3251ddfc | 10 years ago |
Martin Zimmermann | 7886c20aef | 10 years ago |
Martin Zimmermann | d472262fee | 10 years ago |
Martin Zimmermann | 80cbf2676f | 10 years ago |
Martin Zimmermann | 4f152d03ac | 10 years ago |
Martin Zimmermann | 10960ecf1e | 10 years ago |
Martin Zimmermann | 1e542e612a | 10 years ago |
Martin Zimmermann | a551271743 | 10 years ago |
Martin Zimmermann | 88689c789a | 10 years ago |
Martin Zimmermann | bbd9e1b523 | 10 years ago |
Martin Zimmermann | b2bc582f92 | 10 years ago |
Martin Zimmermann | 5f71b735e5 | 10 years ago |
Martin Zimmermann | bffcc3af6f | 10 years ago |
Martin Zimmermann | 1a4e760fe0 | 10 years ago |
Martin Zimmermann | 65caa7ad99 | 10 years ago |
@ -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]
|
@ -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
|
@ -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
|
||||
|
@ -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
|
@ -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()
|
@ -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
|
||||
|
@ -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>`
|
@ -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.
|
||||
|
@ -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``
|
@ -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 .sa import SACache
|
||||
from .uwsgi import uWSGICache
|
||||
|
||||
__all__ = ["Cache", "SACache", "uWSGICache"]
|
@ -0,0 +1,83 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import time
|
||||
|
||||
from sqlalchemy import Table, Column, MetaData, create_engine
|
||||
from sqlalchemy import Float, String, LargeBinary
|
||||
from sqlalchemy.sql import select, func
|
||||
|
||||
from . import Base
|
||||
|
||||
|
||||
def get(con, cache, key):
|
||||
rv = con.execute(
|
||||
select([cache.c.value]).where(
|
||||
cache.c.key == key)).fetchone()
|
||||
|
||||
if rv is None:
|
||||
raise KeyError
|
||||
|
||||
return rv[0]
|
||||
|
||||
|
||||
def set(con, cache, key, value, threshold):
|
||||
cnt = con.execute(
|
||||
select([func.count(cache)])).fetchone()[0]
|
||||
|
||||
if cnt + 1 > threshold:
|
||||
con.execute(
|
||||
cache.delete().where(
|
||||
cache.c.key.in_(select(
|
||||
[cache.c.key])
|
||||
.order_by(cache.c.time)
|
||||
.limit(1))))
|
||||
|
||||
try:
|
||||
get(con, cache, key)
|
||||
except KeyError:
|
||||
insert = True
|
||||
else:
|
||||
insert = False
|
||||
|
||||
if insert:
|
||||
stmt = cache.insert().values(key=key, value=value, time=time.time())
|
||||
else:
|
||||
stmt = cache.update().values(value=value, time=time.time()) \
|
||||
.where(cache.c.key == key)
|
||||
|
||||
con.execute(stmt)
|
||||
|
||||
|
||||
def delete(con, cache, key):
|
||||
con.execute(cache.delete(cache.c.key == key))
|
||||
|
||||
|
||||
class SACache(Base):
|
||||
"""Implements cache using SQLAlchemy Core.
|
||||
"""
|
||||
|
||||
serialize = True
|
||||
|
||||
def __init__(self, db, threshold=1024, timeout=-1):
|
||||
super(SACache, self).__init__(threshold, timeout)
|
||||
self.metadata = MetaData()
|
||||
self.engine = create_engine(db)
|
||||
|
||||
self.cache = Table("cache", self.metadata,
|
||||
Column("key", String(255), primary_key=True),
|
||||
Column("value", LargeBinary(65535)),
|
||||
Column("time", Float))
|
||||
|
||||
self.metadata.create_all(self.engine)
|
||||
self.engine.execute(self.cache.delete())
|
||||
|
||||
def _get(self, ns, key):
|
||||
return get(self.engine.connect(), self.cache, ns + b'-' + key)
|
||||
|
||||
def _set(self, ns, key, value):
|
||||
set(self.engine.connect(), self.cache, ns + b'-' + key, value, self.threshold)
|
||||
|
||||
def _delete(self, ns, key):
|
||||
delete(self.engine.connect(), self.cache, ns + b'-' + key)
|
@ -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
|
||||
text_type = str
|
||||
string_types = (str, )
|
||||
import sys
|
||||
PY2K = sys.version_info[0] == 2
|
||||
|
||||
if not PY2K:
|
||||
buffer = memoryview
|
||||
filter, map, zip = filter, map, zip
|
||||
|
||||
def iteritems(dikt):
|
||||
return iter(dikt.items()) # noqa: E731
|
||||
map, zip, filter = map, zip, filter
|
||||
from functools import reduce
|
||||
|
||||
iteritems = lambda dikt: iter(dikt.items())
|
||||
|
||||
text_type = str
|
||||
string_types = (str, )
|
||||
|
||||
buffer = memoryview
|
||||
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
|
||||
|
@ -0,0 +1,291 @@
|
||||
# -*- 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 empty(self):
|
||||
return self.db.engine.execute(
|
||||
select([func.count(self.db.comments)])).fetchone()[0] == 0
|
||||
|
||||
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()
|
||||
|
||||
return self.db.engine.execute(self.db.comments
|
||||
.delete()
|
||||
.where(self.db.comments.c.mode == 2)
|
||||
.where(now - self.db.comments.c.created > delta)).rowcount
|
@ -0,0 +1,45 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from sqlalchemy.sql import select, not_
|
||||
|
||||
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))
|
||||
|
||||
def prune(self):
|
||||
|
||||
return self.db.engine.execute(self.db.threads
|
||||
.delete()
|
||||
.where(not_(self.db.threads.c.id.in_(
|
||||
select([self.db.comments.c.thread]))))).rowcount
|
@ -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;
|
||||
}
|
@ -0,0 +1,259 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class Adapter(object):
|
||||
|
||||
MAX_VERSION = 3
|
||||
|
||||
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 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
|
||||
a trigger for automated orphan removal.
|
||||
"""
|
||||
|
||||
MAX_VERSION = 3
|
||||
|
||||
def __init__(self, conn, conf):
|
||||
self.connection = conn
|
||||
self.conf = conf
|
||||
|
||||
rv = self.execute([
|
||||
"SELECT name FROM sqlite_master"
|
||||
" WHERE type='table' AND name IN ('threads', 'comments', 'preferences')"]
|
||||
).fetchone()
|
||||
|
||||
self.preferences = Preferences(self)
|
||||
self.threads = Threads(self)
|
||||
self.comments = Comments(self)
|
||||
self.guard = Guard(self)
|
||||
|
||||
if rv is None:
|
||||
self.execute("PRAGMA user_version = %i" % Adapter.MAX_VERSION)
|
||||
else:
|
||||
self.migrate(to=Adapter.MAX_VERSION)
|
||||
|
||||
self.execute([
|
||||
'CREATE TRIGGER IF NOT EXISTS remove_stale_threads',
|
||||
'AFTER DELETE ON comments',
|
||||
'BEGIN',
|
||||
' DELETE FROM threads WHERE id NOT IN (SELECT tid FROM comments);',
|
||||
'END'])
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self.execute("PRAGMA user_version").fetchone()[0]
|
||||
|
||||
def migrate(self, to):
|
||||
|
||||
if self.version >= to:
|
||||
return
|
||||
|
||||
logger.info("migrate database from version %i to %i", self.version, to)
|
||||
|
||||
# re-initialize voters blob due a bug in the bloomfilter signature
|
||||
# which added older commenter's ip addresses to the current voters blob
|
||||
if self.version == 0:
|
||||
|
||||
from isso.utils import Bloomfilter
|
||||
bf = buffer(Bloomfilter(iterable=["127.0.0.0"]).array)
|
||||
|
||||
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)
|
||||
|
||||
# move [general] session-key to database
|
||||
if self.version == 1:
|
||||
|
||||
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"))
|
||||
|
||||
con.execute('PRAGMA user_version = 2')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
|
||||
# limit max. nesting level to 1
|
||||
if self.version == 2:
|
||||
|
||||
first = lambda rv: list(map(operator.itemgetter(0), rv))
|
||||
|
||||
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:
|
||||
|
||||
ids = [id, ]
|
||||
|
||||
while ids:
|
||||
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('PRAGMA user_version = 3')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
|
||||
def execute(self, sql, args=()):
|
||||
return self.connection.execute(sql, args)
|
@ -1,125 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import sqlite3
|
||||
import logging
|
||||
import operator
|
||||
import os.path
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
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 SQLite3:
|
||||
"""DB-dependend wrapper around SQLite3.
|
||||
|
||||
Runs migration if `user_version` is older than `MAX_VERSION` and register
|
||||
a trigger for automated orphan removal.
|
||||
"""
|
||||
|
||||
MAX_VERSION = 3
|
||||
|
||||
def __init__(self, path, conf):
|
||||
|
||||
self.path = os.path.expanduser(path)
|
||||
self.conf = conf
|
||||
|
||||
rv = self.execute([
|
||||
"SELECT name FROM sqlite_master"
|
||||
" WHERE type='table' AND name IN ('threads', 'comments', 'preferences')"]
|
||||
).fetchone()
|
||||
|
||||
self.preferences = Preferences(self)
|
||||
self.threads = Threads(self)
|
||||
self.comments = Comments(self)
|
||||
self.guard = Guard(self)
|
||||
|
||||
if rv is None:
|
||||
self.execute("PRAGMA user_version = %i" % SQLite3.MAX_VERSION)
|
||||
else:
|
||||
self.migrate(to=SQLite3.MAX_VERSION)
|
||||
|
||||
self.execute([
|
||||
'CREATE TRIGGER IF NOT EXISTS remove_stale_threads',
|
||||
'AFTER DELETE ON comments',
|
||||
'BEGIN',
|
||||
' 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]
|
||||
|
||||
def migrate(self, to):
|
||||
|
||||
if self.version >= to:
|
||||
return
|
||||
|
||||
logger.info("migrate database from version %i to %i", self.version, to)
|
||||
|
||||
# re-initialize voters blob due a bug in the bloomfilter signature
|
||||
# which added older commenter's ip addresses to the current voters blob
|
||||
if self.version == 0:
|
||||
|
||||
from isso.utils import Bloomfilter
|
||||
bf = buffer(Bloomfilter(iterable=["127.0.0.0"]).array)
|
||||
|
||||
with sqlite3.connect(self.path) as con:
|
||||
con.execute('UPDATE comments SET voters=?', (bf, ))
|
||||
con.execute('PRAGMA user_version = 1')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
|
||||
# move [general] session-key to database
|
||||
if self.version == 1:
|
||||
|
||||
with sqlite3.connect(self.path) 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"))
|
||||
|
||||
con.execute('PRAGMA user_version = 2')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
|
||||
# limit max. nesting level to 1
|
||||
if self.version == 2:
|
||||
|
||||
def first(rv):
|
||||
return 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())
|
||||
flattened = defaultdict(set)
|
||||
|
||||
for id in top:
|
||||
|
||||
ids = [id, ]
|
||||
|
||||
while ids:
|
||||
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('PRAGMA user_version = 3')
|
||||
logger.info("%i rows changed", con.total_changes)
|
@ -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]
|
Before Width: | Height: | Size: 11 KiB |
@ -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);
|
||||
}
|
||||
|
@ -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,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"
|
||||
});
|
@ -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"
|
||||
});
|
@ -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,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 }} 年前"
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue