Merge pull request #137 from ankicommunity/release/v2.4.0

This commit is contained in:
Vikash Kothary 2022-07-02 14:38:38 +01:00 committed by GitHub
commit 150da07f66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1429 additions and 1296 deletions

3
.gitignore vendored
View File

@ -5,6 +5,9 @@
/collections /collections
/venv /venv
/config/*
!/config/.env.example
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,python,jupyternotebooks # Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,python,jupyternotebooks
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,python,jupyternotebooks # Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,python,jupyternotebooks

View File

@ -31,13 +31,26 @@ run:
@if [[ -f "scripts/${*}.sh" ]]; then \ @if [[ -f "scripts/${*}.sh" ]]; then \
${BASH} "scripts/${*}.sh"; fi ${BASH} "scripts/${*}.sh"; fi
.PHONY: config #: Create new config file.
config: config/.env.${ENV}
config/.env.%:
@cp -n config/.env.example config/.env.${ENV}
.PHONY: init #: Download Python dependencies. .PHONY: init #: Download Python dependencies.
init: init:
@${POETRY} install @${POETRY} install
.PHONY: build
build:
@${POETRY} build
.PHONY: release #: Create new Git release and tags. .PHONY: release #: Create new Git release and tags.
release: release-branch release-tags release: release-branch release-tags
.PHONY: publish
publish: build
@${POETRY} publish
.PHONY: open .PHONY: open
open: open:
@${OPEN} ${ANKISYNCD_URL} @${OPEN} ${ANKISYNCD_URL}

View File

@ -31,7 +31,9 @@ It supports Python 3 and Anki 2.1.
- [AnkiDroid](#ankidroid) - [AnkiDroid](#ankidroid)
- [Development](#development) - [Development](#development)
- [Testing](#testing) - [Testing](#testing)
- [ENVVAR configuration overrides](#envvar-configuration-overrides) - [Configuration](#configuration)
- [Environment Variables](#environment-variables-preferred)
- [Config File](#config-file-ankisyncdconf)
- [Support for other database backends](#support-for-other-database-backends) - [Support for other database backends](#support-for-other-database-backends)
</details> </details>
@ -42,18 +44,17 @@ Installing
$ pip install -r src/requirements.txt $ pip install -r src/requirements.txt
2. Modify ankisyncd.conf according to your needs 2. Copy the default config file ([ankisyncd.conf](src/ankisyncd.conf)) to configure the server using the command below. Environment variables can be used instead, see: [Configuration](#configuration).
$ cp src/ankisyncd.conf src/ankisyncd/.
3. Create user: 3. Create user:
$ ./ankisyncctl.py adduser <username> $ ./ankisyncctl.py adduser <username>
4. Setup a proxy to unchunk the requests. 4. Setup a proxy to trans-write the requests (Optional) .
Ankisyncd currently support the header "Transfer-Encoding: chunked" used by Anki.
Webob does not support the header "Transfer-Encoding: chunked" used by Anki If you want to enable secure connection or have a better security,a proxy can be set up.
and therefore ankisyncd sees chunked requests as empty. To solve this problem
setup Nginx (or any other webserver of your choice) and configure it to
"unchunk" the requests for ankisyncd.
For example, if you use Nginx on the same machine as ankisyncd, you first For example, if you use Nginx on the same machine as ankisyncd, you first
have to change the port in `ankisyncd.conf` to something other than `27701`. have to change the port in `ankisyncd.conf` to something other than `27701`.
@ -61,7 +62,7 @@ Installing
requests to ankisyncd. requests to ankisyncd.
An example configuration with ankisyncd running on the same machine as Nginx An example configuration with ankisyncd running on the same machine as Nginx
and listening on port `27702` may look like ([entire config template click me](https://github.com/ankicommunity/anki-sync-server/blob/develop/docs/nginx.conf)): and listening on port `27702` may look like ([entire config template click me](docs/src/nginx/nginx.example.conf)):
```nginx ```nginx
server { server {
@ -70,11 +71,14 @@ Installing
location / { location / {
proxy_http_version 1.0; proxy_http_version 1.0;
proxy_pass http://127.0.0.1:27702/; proxy_pass http://127.0.0.1:27702/;
client_max_body_size 222M;
} }
} }
``` ```
Adding the line `client_max_body_size 222M;` to Nginx prevents bigger collections from not being able to sync due to size limitations.
5. Run ankisyncd: 5. Run ankisyncd:
``` ```
@ -88,6 +92,7 @@ Installing (Docker)
Follow [these instructions](https://github.com/ankicommunity/anki-devops-services#about-this-docker-image). Follow [these instructions](https://github.com/ankicommunity/anki-devops-services#about-this-docker-image).
Setting up Anki Setting up Anki
--------------- ---------------
@ -138,7 +143,7 @@ and put it in `~/Anki/addons`.
anki.sync.SYNC_BASE = addr anki.sync.SYNC_BASE = addr
anki.sync.SYNC_MEDIA_BASE = addr + "msync/" anki.sync.SYNC_MEDIA_BASE = addr + "msync/"
[addons21]: https://addon-docs.ankiweb.net/#/getting-started?id=add-on-folders [addons21]: https://addon-docs.ankiweb.net/addon-folders.html
### AnkiDroid ### AnkiDroid
@ -171,7 +176,7 @@ This project uses [GNU Make](https://www.gnu.org/software/make/) to simplify the
$ cp config/.env.example config/.env.local $ cp config/.env.example config/.env.local
``` ```
See [ENVVAR configuration overrides](#envvar-configuration-overrides) for more information. See [Configuration](#configuration) for more information.
2. Download Python dependencies. 2. Download Python dependencies.
@ -185,8 +190,9 @@ $ make init
$ make tests $ make tests
``` ```
ENVVAR configuration overrides ## Configuration
------------------------------
### Environment Variables (preferred)
Configuration values can be set via environment variables using `ANKISYNCD_` prepended Configuration values can be set via environment variables using `ANKISYNCD_` prepended
to the uppercase form of the configuration value. E.g. the environment variable, to the uppercase form of the configuration value. E.g. the environment variable,
@ -194,6 +200,19 @@ to the uppercase form of the configuration value. E.g. the environment variable,
Environment variables override the values set in the `ankisyncd.conf`. Environment variables override the values set in the `ankisyncd.conf`.
* The environment variables can be found here: config/.env.example.
* The file also includes other development variables, but the notable ones are the ones with the prefix ANKISYNCD_
* Environment variables will override the config files values (which is why I recommend you use them)
* This is what we use in the Docker images (see: https://github.com/ankicommunity/anki-devops-services/blob/develop/services/anki-sync-server/examples/docker-compose.yml).
* Copying the config file from config/.env.example to config/.env.local will allow you to configure the server when using the make commands
* You can also set it when running the server e.g. ANKISYNCD_PORT=5001 make run
* The above two options are useful for development. But if you're only going for usage, you can also set it globally by adding it to your ~/.bashrc file e.g. export ANKISYNCD_PORT=50001
### Config File: ankisyncd.conf
A config file can be used to configuring the server. It can be found here: [src/ankisyncd.conf](src/ankisyncd.conf).
Support for other database backends Support for other database backends
----------------------------------- -----------------------------------

View File

@ -25,6 +25,11 @@ MKDOCS_CMD=serve
### JUPYTER_CMD ### JUPYTER_CMD
### JUPYTER_NOTEBOOK_DIR ### JUPYTER_NOTEBOOK_DIR
## Poetry
### POETRY_PYPI_TOKEN_PYPI=
### POETRY_HTTP_BASIC_PYPI_USERNAME=
### POETRY_HTTP_BASIC_PYPI_PASSWORD=
## Make ## Make
AWK=awk AWK=awk
BASH=bash BASH=bash

2021
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,12 +4,12 @@ version = "2.3.0"
description = "Self-hosted Anki Sync Server." description = "Self-hosted Anki Sync Server."
authors = ["Vikash Kothary <kothary.vikash@gmail.com>"] authors = ["Vikash Kothary <kothary.vikash@gmail.com>"]
packages = [ packages = [
{ include = "ankisyncd", from = "src" } { include = "*", from = "src" }
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.8" python = "^3.8"
anki = "2.1.43" anki = "2.1.49"
beautifulsoup4 = "^4.9.1" beautifulsoup4 = "^4.9.1"
requests = "^2.24.0" requests = "^2.24.0"
markdown = "^3.2.2" markdown = "^3.2.2"

10
scripts/poetry-update.sh Normal file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# file: poetry-update.sh
# description: Update Python dependencies to latest.
# POETRY_UPDATE_OPTS=--dry-run
# POETRY_UPDATE_ARGS=anki
poetry update "${POETRY_UPDATE_OPTS}" "${POETRY_UPDATE_ARGS}"
# TODO: Run poetry export
# TODO: Create git branch and commit

View File

@ -15,7 +15,7 @@ if [[ "${GIT_BRANCH}" != "main" ]]; then
fi fi
## TODO: get package version from pyproject.toml ## TODO: get package version from pyproject.toml
CURRENT_VERSION=2.3.0 CURRENT_VERSION=v2.3.0
## Create GitHub Release ## Create GitHub Release
git tag -a ${CURRENT_VERSION} -m "v${CURRENT_VERSION}" git tag -a ${CURRENT_VERSION} -m "v${CURRENT_VERSION}"

View File

@ -41,6 +41,21 @@ class Syncer(object):
self.col = col self.col = col
self.server = server self.server = server
# new added functions related to Syncer:
# these are removed from latest anki module
########################################################################
def scm(self):
"""return schema"""
scm=self.col.db.scalar("select scm from col")
return scm
def increment_usn(self):
"""usn+1 in db"""
self.col.db.execute("update col set usn = usn + 1")
def set_modified_time(self,now:int):
self.col.db.execute("update col set mod=?", now)
def set_last_sync(self,now:int):
self.col.db.execute("update col set ls = ?", now)
#########################################################################
def meta(self): def meta(self):
return dict( return dict(
mod=self.col.mod, mod=self.col.mod,
@ -58,7 +73,7 @@ class Syncer(object):
decks=self.getDecks(), decks=self.getDecks(),
tags=self.getTags()) tags=self.getTags())
if self.lnewer: if self.lnewer:
d['conf'] = json.loads(self.col.backend.get_all_config()) d['conf'] = self.col.all_config()
d['crt'] = self.col.crt d['crt'] = self.col.crt
return d return d
@ -66,7 +81,6 @@ class Syncer(object):
# then the other objects # then the other objects
self.mergeModels(rchg['models']) self.mergeModels(rchg['models'])
self.mergeDecks(rchg['decks']) self.mergeDecks(rchg['decks'])
self.mergeTags(rchg['tags'])
if 'conf' in rchg: if 'conf' in rchg:
self.mergeConf(rchg['conf']) self.mergeConf(rchg['conf'])
# this was left out of earlier betas # this was left out of earlier betas
@ -105,50 +119,50 @@ select id from notes where mid = ?) limit 1"""
return False return False
return True return True
def sanityCheck(self, full): def sanityCheck(self):
if not self.basicCheck(): tables=["cards",
return "failed basic check" "notes",
for t in "cards", "notes", "revlog", "graves": "revlog",
if self.col.db.scalar( "graves",
"select count() from %s where usn = -1" % t): "decks",
return "%s had usn = -1" % t "deck_config",
for g in self.col.decks.all(): "tags",
if g['usn'] == -1: "notetypes",
return "deck had usn = -1" ]
for t, usn in self.allItems(): for tb in tables:
if usn == -1: if self.col.db.scalar(f'select null from {tb} where usn=-1'):
return "tag had usn = -1" return f'table had usn=-1: {tb}'
found = False
for m in self.col.models.all():
if m['usn'] == -1:
return "model had usn = -1"
if found:
self.col.models.save()
self.col.sched.reset() self.col.sched.reset()
# check for missing parent decks
#self.col.sched.deckDueList()
# return summary of deck # return summary of deck
# make sched.counts() equal to default [0,0,0]
# to make sure sync normally if sched.counts()
# are not equal between different clients due to
# different deck selection
return [ return [
list(self.col.sched.counts()), list([0,0,0]),
self.col.db.scalar("select count() from cards"), self.col.db.scalar("select count() from cards"),
self.col.db.scalar("select count() from notes"), self.col.db.scalar("select count() from notes"),
self.col.db.scalar("select count() from revlog"), self.col.db.scalar("select count() from revlog"),
self.col.db.scalar("select count() from graves"), self.col.db.scalar("select count() from graves"),
len(self.col.models.all()), len(self.col.models.all()),
len(self.col.decks.all()), len(self.col.decks.all()),
len(self.col.decks.allConf()), len(self.col.decks.all_config()),
] ]
def usnLim(self): def usnLim(self):
return "usn = -1" return "usn = -1"
def finish(self, mod=None): def finish(self, now=None):
self.col.ls = mod if now is not None:
self.col._usn = self.maxUsn + 1
# ensure we save the mod time even if no changes made # ensure we save the mod time even if no changes made
self.col.db.mod = True self.set_modified_time(now)
self.col.save(mod=mod) self.set_last_sync(now)
return mod self.increment_usn()
self.col.save()
return now
# even though that now is None will not happen,have to match a gurad case
return None
# Chunked syncing # Chunked syncing
########################################################################## ##########################################################################
@ -195,67 +209,26 @@ from notes where %s""" % lim, self.maxUsn)
# Deletions # Deletions
########################################################################## ##########################################################################
def removed(self): def add_grave(self, ids: List[int], type: int,usn: int):
cards = [] items=[(id,type,usn) for id in ids]
notes = [] # make sure table graves fields order and schema version match
decks = [] # query sql1='pragma table_info(graves)' version query schema='select ver from col'
self.col.db.executemany(
"INSERT OR IGNORE INTO graves (oid, type, usn) VALUES (?, ?, ?)" ,
items)
curs = self.col.db.execute( def apply_graves(self, graves,latest_usn: int):
"select oid, type from graves where usn = -1")
for oid, type in curs:
if type == REM_CARD:
cards.append(oid)
elif type == REM_NOTE:
notes.append(oid)
else:
decks.append(oid)
self.col.db.execute("update graves set usn=? where usn=-1",
self.maxUsn)
return dict(cards=cards, notes=notes, decks=decks)
def remove(self, graves):
# remove card and the card's orphaned notes # remove card and the card's orphaned notes
self.col.remove_cards_and_orphaned_notes(graves['cards']) self.col.remove_cards_and_orphaned_notes(graves['cards'])
self.add_grave(graves['cards'], REM_CARD,latest_usn)
# only notes # only notes
self.col.remove_notes(graves['notes']) self.col.remove_notes(graves['notes'])
self.add_grave(graves['notes'], REM_NOTE,latest_usn)
# since level 0 deck ,we only remove deck ,but backend will delete child,it is ok, the delete # since level 0 deck ,we only remove deck ,but backend will delete child,it is ok, the delete
# will have once effect # will have once effect
for oid in graves['decks']: self.col.decks.remove(graves['decks'])
self.col.decks.rem(oid) self.add_grave(graves['decks'], REM_DECK,latest_usn)
# we can place non-exist grave after above delete.
localgcards = []
localgnotes = []
localgdecks = []
curs = self.col.db.execute(
"select oid, type from graves where usn = %d" % self.col.usn())
for oid, type in curs:
if type == REM_CARD:
localgcards.append(oid)
elif type == REM_NOTE:
localgnotes.append(oid)
else:
localgdecks.append(oid)
# n meaning non-exsiting grave in the server compared to client
ncards = [ oid for oid in graves['cards'] if oid not in localgcards]
for oid in ncards:
self.col._logRem([oid], REM_CARD)
nnotes = [ oid for oid in graves['notes'] if oid not in localgnotes]
for oid in nnotes:
self.col._logRem([oid], REM_NOTE)
ndecks = [ oid for oid in graves['decks'] if oid not in localgdecks]
for oid in ndecks:
self.col._logRem([oid], REM_DECK)
# Models # Models
########################################################################## ##########################################################################
@ -342,7 +315,8 @@ from notes where %s""" % lim, self.maxUsn)
for r in data: for r in data:
if r[0] not in lmods or lmods[r[0]] < r[modIdx]: if r[0] not in lmods or lmods[r[0]] < r[modIdx]:
update.append(r) update.append(r)
self.col.log(table, data) # replace col.log by just using print
print(table, data)
return update return update
def mergeCards(self, cards): def mergeCards(self, cards):
@ -356,7 +330,7 @@ from notes where %s""" % lim, self.maxUsn)
self.col.db.executemany( self.col.db.executemany(
"insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)", "insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)",
rows) rows)
self.col.updateFieldCache([f[0] for f in rows]) self.col.after_note_updates([f[0] for f in rows], mark_modified=False, generate_cards=False)
# Col config # Col config
########################################################################## ##########################################################################

View File

@ -15,29 +15,25 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import gzip import gzip
import hashlib
import io import io
import json import json
import logging import logging
import os import os
import random import random
import re import re
import string
import sys import sys
import time import time
import unicodedata import unicodedata
import zipfile import zipfile
from configparser import ConfigParser import types
from sqlite3 import dbapi2 as sqlite
from webob import Response from webob import Response
from webob.dec import wsgify
from webob.exc import * from webob.exc import *
import urllib.parse
from functools import wraps
from anki.collection import Collection
import anki.db import anki.db
import anki.utils import anki.utils
from anki.consts import REM_CARD, REM_NOTE from anki.consts import REM_CARD, REM_NOTE
from ankisyncd.full_sync import get_full_sync_manager from ankisyncd.full_sync import get_full_sync_manager
from ankisyncd.sessions import get_session_manager from ankisyncd.sessions import get_session_manager
from ankisyncd.sync import Syncer, SYNC_VER, SYNC_ZIP_SIZE, SYNC_ZIP_COUNT from ankisyncd.sync import Syncer, SYNC_VER, SYNC_ZIP_SIZE, SYNC_ZIP_COUNT
@ -97,8 +93,8 @@ class SyncCollectionHandler(Syncer):
return { return {
'mod': self.col.mod, 'mod': self.col.mod,
'scm': self.col.scm, 'scm': self.scm(),
'usn': self.col._usn, 'usn': self.col.usn(),
'ts': anki.utils.intTime(), 'ts': anki.utils.intTime(),
'musn': self.col.media.lastUsn(), 'musn': self.col.media.lastUsn(),
'uname': self.session.name, 'uname': self.session.name,
@ -117,19 +113,20 @@ class SyncCollectionHandler(Syncer):
# Since now have not thorougly test the V2 scheduler, we leave this comments here, and # Since now have not thorougly test the V2 scheduler, we leave this comments here, and
# just enable the V2 scheduler in the serve code. # just enable the V2 scheduler in the serve code.
self.maxUsn = self.col._usn self.maxUsn = self.col.usn()
self.minUsn = minUsn self.minUsn = minUsn
self.lnewer = not lnewer self.lnewer = not lnewer
# fetch local/server graves
lgraves = self.removed() lgraves = self.removed()
# convert grave:None to {'cards': [], 'notes': [], 'decks': []} # handle AnkiDroid using old protocol
# because req.POST['data'] returned value of grave is None # Only if Operations like deleting deck are performed on Ankidroid
if graves==None: # can (client) graves is not None
graves={'cards': [], 'notes': [], 'decks': []} if graves is not None:
self.remove(graves) self.apply_graves(graves,self.maxUsn)
return lgraves return lgraves
def applyGraves(self, chunk): def applyGraves(self, chunk):
self.remove(chunk) self.apply_graves(chunk,self.maxUsn)
def applyChanges(self, changes): def applyChanges(self, changes):
self.rchg = changes self.rchg = changes
@ -138,17 +135,18 @@ class SyncCollectionHandler(Syncer):
self.mergeChanges(lchg, self.rchg) self.mergeChanges(lchg, self.rchg)
return lchg return lchg
def sanityCheck2(self, client, full=None): def sanityCheck2(self, client):
server = self.sanityCheck(full) client[0]=[0,0,0]
server = self.sanityCheck()
if client != server: if client != server:
logger.info( logger.info(
f"sanity check failed with server: {server} client: {client}" f"sanity check failed with server: {server} client: {client}"
) )
return dict(status="bad", c=client, s=server) return dict(status="bad", c=client, s=server)
return dict(status="ok") return dict(status="ok")
def finish(self, mod=None):
def finish(self):
return super().finish(anki.utils.intTime(1000)) return super().finish(anki.utils.intTime(1000))
# This function had to be put here in its entirety because Syncer.removed() # This function had to be put here in its entirety because Syncer.removed()
@ -178,7 +176,7 @@ class SyncCollectionHandler(Syncer):
def getDecks(self): def getDecks(self):
return [ return [
[g for g in self.col.decks.all() if g['usn'] >= self.minUsn], [g for g in self.col.decks.all() if g['usn'] >= self.minUsn],
[g for g in self.col.decks.allConf() if g['usn'] >= self.minUsn] [g for g in self.col.decks.all_config() if g['usn'] >= self.minUsn]
] ]
def getTags(self): def getTags(self):
@ -338,7 +336,6 @@ class SyncMediaHandler:
if lastUsn < server_lastUsn or lastUsn == 0: if lastUsn < server_lastUsn or lastUsn == 0:
for fname,usn,csum, in self.col.media.changes(lastUsn): for fname,usn,csum, in self.col.media.changes(lastUsn):
result.append([fname, usn, csum]) result.append([fname, usn, csum])
# anki assumes server_lastUsn == result[-1][1] # anki assumes server_lastUsn == result[-1][1]
# ref: anki/sync.py:720 (commit cca3fcb2418880d0430a5c5c2e6b81ba260065b7) # ref: anki/sync.py:720 (commit cca3fcb2418880d0430a5c5c2e6b81ba260065b7)
result.reverse() result.reverse()
@ -394,7 +391,137 @@ class SyncUserSession:
# for inactivity and then later re-open it (creating a new Collection object). # for inactivity and then later re-open it (creating a new Collection object).
handler.col = col handler.col = col
return handler return handler
class Requests(object):
def __init__(self,environ: dict):
self.environ=environ
@property
def params(self):
return self.request_items_dict
@params.setter
def params(self,value):
"""
A dictionary-like object containing both the parameters from
the query string and request body.
"""
self.request_items_dict= value
@property
def path(self)-> str:
return self.environ['PATH_INFO']
@property
def POST(self):
return self._request_items_dict
@POST.setter
def POST(self,value):
self._request_items_dict=value
@property
def parse(self):
'''Return a MultiDict containing all the variables from a form
request.\n
include not only post req,but also get'''
env = self.environ
query_string=env['QUERY_STRING']
content_len= env.get('CONTENT_LENGTH', '0')
input = env.get('wsgi.input')
length = 0 if content_len == '' else int(content_len)
body=b''
request_items_dict={}
if length == 0:
if input is None:
return request_items_dict
if env.get('HTTP_TRANSFER_ENCODING','0') == 'chunked':
# readlines and read(no argument) will block
# convert byte str to number base 16
leng=int(input.readline(),16)
c=0
bdry=b''
data=[]
data_other=[]
while leng >0:
c+=1
dt = input.read(leng+2)
if c==1:
bdry=dt
elif c>=3:
# data
data_other.append(dt)
leng = int(input.readline(),16)
data_other=[item for item in data_other if item!=b'\r\n\r\n']
for item in data_other:
if bdry in item:
break
# only strip \r\n if there are extra \n
# eg b'?V\xc1\x8f>\xf9\xb1\n\r\n'
data.append(item[:-2])
request_items_dict['data']=b''.join(data)
others=data_other[len(data):]
boundary=others[0]
others=b''.join(others).split(boundary.strip())
others.pop()
others.pop(0)
for i in others:
i=i.splitlines()
key=re.findall(b'name="(.*?)"',i[2],flags=re.M)[0].decode('utf-8')
v=i[-1].decode('utf-8')
request_items_dict[key]=v
return request_items_dict
if query_string !='':
# GET method
body=query_string
request_items_dict=urllib.parse.parse_qs(body)
for k,v in request_items_dict.items():
request_items_dict[k]=''.join(v)
return request_items_dict
else:
body = env['wsgi.input'].read(length)
if body is None or body ==b'':
return request_items_dict
# process body to dict
repeat=body.splitlines()[0]
items=re.split(repeat,body)
# del first ,last item
items.pop()
items.pop(0)
for item in items:
if b'name="data"' in item:
data_field=None
# remove \r\n
if b'application/octet-stream' in item:
# Ankidroid case
item=re.sub(b'Content-Disposition: form-data; name="data"; filename="data"',b'',item)
item=re.sub(b'Content-Type: application/octet-stream',b'',item)
data_field=item.strip()
else:
# PKzip file stream and others
item=re.sub(b'Content-Disposition: form-data; name="data"; filename="data"',b'',item)
data_field=item.strip()
request_items_dict['data']=data_field
continue
item=re.sub(b'\r\n',b'',item,flags=re.MULTILINE)
key=re.findall(b'name="(.*?)"',item)[0].decode('utf-8')
v=item[item.rfind(b'"')+1:].decode('utf-8')
request_items_dict[key]=v
return request_items_dict
class chunked(object):
'''decorator'''
def __init__(self, func):
wraps(func)(self)
def __call__(self, *args, **kwargs):
clss=args[0]
environ=args[1]
start_response = args[2]
b=Requests(environ)
args=(clss,b,)
w= self.__wrapped__(*args, **kwargs)
resp=Response(w)
return resp(environ, start_response)
def __get__(self, instance, cls):
if instance is None:
return self
else:
return types.MethodType(self, instance)
class SyncApp: class SyncApp:
valid_urls = SyncCollectionHandler.operations + SyncMediaHandler.operations + ['hostKey', 'upload', 'download'] valid_urls = SyncCollectionHandler.operations + SyncMediaHandler.operations + ['hostKey', 'upload', 'download']
@ -467,16 +594,18 @@ class SyncApp:
# local copy in Anki # local copy in Anki
return self.full_sync_manager.download(col, session) return self.full_sync_manager.download(col, session)
@wsgify @chunked
def __call__(self, req): def __call__(self, req):
# Get and verify the session # cgi file can only be read once,and will be blocked after being read once more
# so i call Requests.parse only once,and bind its return result to properties
# POST and params (set return result as property values)
req.params=req.parse
req.POST=req.params
try: try:
hkey = req.params['k'] hkey = req.params['k']
except KeyError: except KeyError:
hkey = None hkey = None
session = self.session_manager.load(hkey, self.create_session) session = self.session_manager.load(hkey, self.create_session)
if session is None: if session is None:
try: try:
skey = req.POST['sk'] skey = req.POST['sk']
@ -490,7 +619,7 @@ class SyncApp:
compression = 0 compression = 0
try: try:
data = req.POST['data'].file.read() data = req.POST['data']
data = self._decode_data(data, compression) data = self._decode_data(data, compression)
except KeyError: except KeyError:
data = {} data = {}
@ -631,4 +760,5 @@ def main():
finally: finally:
shutdown() shutdown()
if __name__ == '__main__': main() if __name__ == '__main__':
main()

View File

View File

@ -0,0 +1,4 @@
from ankisyncd_cli import ankisyncctl
if __name__ == '__main__':
ankisyncctl.main()

View File

@ -1,13 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os
import sys import sys
import getpass import getpass
import ankisyncd.config from ankisyncd import config
from ankisyncd.users import get_user_manager from ankisyncd.users import get_user_manager
config = ankisyncd.config.load() config = config.load()
def usage(): def usage():
print("usage: {} <command> [<args>]".format(sys.argv[0])) print("usage: {} <command> [<args>]".format(sys.argv[0]))

View File

@ -1,113 +1,109 @@
# THE FILE WAS GENERATED BY POETRY, DO NOT EDIT! # THE FILE WAS GENERATED BY POETRY, DO NOT EDIT!
anki==2.1.43; python_version >= "3.8" anki==2.1.49
appnope==0.1.2; platform_system == "Darwin" and python_version >= "3.8" and sys_platform == "darwin" appnope==0.1.3; platform_system == "Darwin" or sys_platform == "darwin" or python_version >= "3.3" and sys_platform == "darwin"
argon2-cffi-bindings==21.2.0; python_version >= "3.6" argon2-cffi==21.3.0
argon2-cffi==21.3.0; python_version >= "3.6" argon2-cffi-bindings==21.2.0
asttokens==2.0.5; python_version >= "3.8" asttokens==2.0.5
attrs==21.4.0; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" attrs==21.4.0
backcall==0.2.0; python_version >= "3.8" backcall==0.2.0
beautifulsoup4==4.10.0; python_full_version > "3.0.0" beautifulsoup4==4.11.1
black==21.12b0; python_full_version >= "3.6.2" and python_version >= "3.8" bleach==5.0.1
bleach==4.1.0; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" certifi==2022.6.15
certifi==2021.10.8; python_version >= "3.8" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.8" cffi==1.15.1
cffi==1.15.0; implementation_name == "pypy" and python_version >= "3.7" and python_full_version >= "3.6.1" charset-normalizer==2.1.0
charset-normalizer==2.0.10; python_full_version >= "3.6.0" and python_version >= "3.8" click==8.1.3
click==8.0.3; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.8" colorama==0.4.5; python_version >= "3.3" and sys_platform == "win32" or sys_platform == "win32" or platform_system == "Windows"
colorama==0.4.4; python_version >= "3.8" and python_full_version < "3.0.0" and platform_system == "Windows" and sys_platform == "win32" or platform_system == "Windows" and python_version >= "3.8" and python_full_version >= "3.5.0" and sys_platform == "win32" debugpy==1.6.0
debugpy==1.5.1; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.7" decorator==4.4.2
decorator==4.4.2; (python_version >= "2.6" and python_full_version < "3.0.0") or (python_full_version >= "3.2.0") defusedxml==0.7.1
defusedxml==0.7.1; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" distro==1.7.0
distro==1.6.0 entrypoints==0.4
entrypoints==0.3; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" executing==0.8.3
executing==0.8.2; python_version >= "3.8" fastjsonschema==2.15.3
ghp-import==2.0.2; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.6" ghp-import==2.1.0
idna==3.3; python_version >= "3.8" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.8" idna==3.3
importlib-metadata==4.10.0; python_version < "3.10" and python_version >= "3.7" and python_full_version >= "3.7.1" importlib-metadata==4.12.0
importlib-resources==5.4.0; python_version < "3.9" and python_version >= "3.7" and python_full_version >= "3.7.1" importlib-resources==5.8.0; python_version < "3.9"
ipykernel==6.7.0; python_version >= "3.7" ipykernel==6.15.0
ipython-genutils==0.2.0; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" ipython==8.4.0
ipython==8.0.0; python_version >= "3.8" ipython-genutils==0.2.0
ipywidgets==7.6.5 ipywidgets==7.7.1
jedi==0.18.1; python_version >= "3.8" jedi==0.18.1
jinja2==3.0.3; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" jinja2==3.1.2
json5==0.9.6; python_version >= "3.5" json5==0.9.8
jsonschema==4.4.0; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" jsonschema==4.6.1
jupyter-client==7.1.1; python_full_version >= "3.7.1" and python_version >= "3.7" and python_version < "4"
jupyter-console==6.4.0; python_version >= "3.6"
jupyter-core==4.9.1; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7"
jupyter==1.0.0 jupyter==1.0.0
jupyterlab-pygments==0.1.2; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" jupyter-client==7.3.4
jupyterlab-server==1.2.0; python_version >= "3.5" jupyter-console==6.4.4
jupyterlab-widgets==1.0.2; python_version >= "3.6" jupyter-core==4.10.0
jupyterlab==2.3.2; python_version >= "3.5" jupyterlab==2.3.2
jupytext==1.13.6; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.7.1" jupyterlab-pygments==0.2.2
markdown-it-py==1.1.0; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.7.1" jupyterlab-server==1.2.0
markdown==3.3.6; python_version >= "3.6" jupyterlab-widgets==1.1.1; python_version >= "3.6"
markupsafe==2.0.1; python_version >= "3.6" jupytext==1.13.8
matplotlib-inline==0.1.3; python_version >= "3.8" markdown==3.3.7
mdit-py-plugins==0.3.0; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.7.1" markdown-it-py==2.1.0
mergedeep==1.3.4; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.6" markupsafe==2.1.1
mistune==0.8.4; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" matplotlib-inline==0.1.3
mkdocs-jupyter==0.19.0; python_full_version >= "3.7.1" and python_version < "4" mdit-py-plugins==0.3.0
mkdocs-material-extensions==1.0.3; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.6" mdurl==0.1.1
mkdocs-material==8.1.7; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.6" mergedeep==1.3.4
mkdocs==1.2.3; python_version >= "3.6" mistune==0.8.4
mypy-extensions==0.4.3; python_full_version >= "3.6.2" and python_version >= "3.8" mkdocs==1.3.0
nbclient==0.5.10; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" mkdocs-jupyter==0.19.0
nbconvert==6.4.0; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" mkdocs-material==8.3.8
nbformat==5.1.3; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" mkdocs-material-extensions==1.0.3
nest-asyncio==1.5.4; python_full_version >= "3.7.1" and python_version >= "3.7" and python_version < "4" nbclient==0.6.6
notebook==6.4.7; python_version >= "3.6" nbconvert==6.5.0
orjson==3.6.5; platform_machine == "x86_64" and python_version >= "3.8" nbformat==5.4.0
packaging==21.3; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" nest-asyncio==1.5.5
pandocfilters==1.5.0; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" notebook==6.4.12
parso==0.8.3; python_version >= "3.8" orjson==3.7.5; platform_machine == "x86_64"
pathspec==0.9.0; python_full_version >= "3.6.2" and python_version >= "3.8" packaging==21.3
pexpect==4.8.0; sys_platform != "win32" and python_version >= "3.8" pandocfilters==1.5.0
pickleshare==0.7.5; python_version >= "3.8" parso==0.8.3
platformdirs==2.4.1; python_full_version >= "3.6.2" and python_version >= "3.8" pexpect==4.8.0; python_version >= "3.3" and sys_platform != "win32" or sys_platform != "win32"
prometheus-client==0.12.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" pickleshare==0.7.5
prompt-toolkit==3.0.24; python_full_version >= "3.6.2" and python_version >= "3.8" prometheus-client==0.14.1
protobuf==3.19.3; python_version >= "3.8" prompt-toolkit==3.0.30
psutil==5.9.0; (python_version >= "2.6" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0") protobuf==4.21.2
ptyprocess==0.7.0; os_name != "nt" and python_version >= "3.8" and sys_platform != "win32" psutil==5.9.1
pure-eval==0.2.1; python_version >= "3.8" ptyprocess==0.7.0; sys_platform != "win32" or os_name != "nt" or python_version >= "3.3" and sys_platform != "win32"
py==1.11.0; implementation_name == "pypy" and python_version >= "3.7" and python_full_version >= "3.6.1" pure-eval==0.2.2
pycparser==2.21; implementation_name == "pypy" and python_version >= "3.7" and python_full_version >= "3.6.1" py==1.11.0; implementation_name == "pypy"
pygments==2.11.2; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.8" pycparser==2.21
pymdown-extensions==9.1; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.6" pygments==2.12.0
pyparsing==3.0.6; python_version >= "3.6" pymdown-extensions==9.5
pyrsistent==0.18.1; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" pyparsing==3.0.9
pysocks==1.7.1; python_version >= "3.8" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.8" pyrsistent==0.18.1
python-dateutil==2.8.2; python_full_version >= "3.6.1" and python_version >= "3.7" python-dateutil==2.8.2
pywin32==303; sys_platform == "win32" and platform_python_implementation != "PyPy" and python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" pywin32==304; sys_platform == "win32" and platform_python_implementation != "PyPy"
pywinpty==1.1.6; os_name == "nt" and python_version >= "3.6" pywinpty==2.0.5; os_name == "nt"
pyyaml-env-tag==0.1; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.6" pyyaml==6.0
pyyaml==6.0; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.7.1" pyyaml-env-tag==0.1
pyzmq==22.3.0; python_full_version >= "3.6.1" and python_version >= "3.7" pyzmq==23.2.0
qtconsole==5.2.2; python_version >= "3.6" qtconsole==5.3.1
qtpy==2.0.0; python_version >= "3.6" qtpy==2.1.0
requests==2.27.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0") requests==2.28.1
send2trash==1.8.0 send2trash==1.8.0
six==1.16.0; python_full_version >= "3.7.1" and python_version >= "3.7" and python_version < "4" and (python_version >= "3.8" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.8") six==1.16.0
soupsieve==2.3.1; python_full_version > "3.0.0" and python_version >= "3.8" soupsieve==2.3.2.post1
stack-data==0.1.4; python_version >= "3.8" stack-data==0.3.0
terminado==0.12.1; python_version >= "3.6" stringcase==1.2.0
testpath==0.5.0; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" terminado==0.15.0
toml==0.10.2; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.7.1" tinycss2==1.1.1
tomli==1.2.3; python_full_version >= "3.6.2" and python_version >= "3.8" toml==0.10.2
tornado==6.1; python_full_version >= "3.6.1" and python_version >= "3.7" tornado==6.1
traitlets==5.1.1; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.8" traitlets==5.3.0
typing-extensions==4.0.1 urllib3==1.26.9
urllib3==1.26.8; python_version >= "3.8" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version < "4" and python_version >= "3.8" waitress==2.1.2
waitress==2.0.0; python_full_version >= "3.6.0" watchdog==2.1.9
watchdog==2.1.6; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.6" wcwidth==0.2.5
wcwidth==0.2.5; python_full_version >= "3.6.2" and python_version >= "3.8" webencodings==0.5.1
webencodings==0.5.1; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.7" webob==1.8.7
webob==1.8.7; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.3.0")
webtest==2.0.35 webtest==2.0.35
widgetsnbextension==3.5.2 widgetsnbextension==3.6.1
zipp==3.7.0; python_version < "3.9" and python_version >= "3.7" and python_full_version >= "3.7.1" zipp==3.8.0
-e src/. -e src/.

View File

@ -1,22 +1,22 @@
# THE FILE WAS GENERATED BY POETRY, DO NOT EDIT! # THE FILE WAS GENERATED BY POETRY, DO NOT EDIT!
anki==2.1.43; python_version >= "3.8" anki==2.1.49
beautifulsoup4==4.10.0; python_full_version > "3.0.0" beautifulsoup4==4.11.1
certifi==2021.10.8; python_version >= "3.8" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.8" certifi==2022.6.15
charset-normalizer==2.0.10; python_full_version >= "3.6.0" and python_version >= "3.8" charset-normalizer==2.1.0
decorator==4.4.2; (python_version >= "2.6" and python_full_version < "3.0.0") or (python_full_version >= "3.2.0") decorator==4.4.2
distro==1.6.0 distro==1.7.0
idna==3.3; python_version >= "3.8" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.8" idna==3.3
importlib-metadata==4.10.0; python_version < "3.10" and python_version >= "3.7" importlib-metadata==4.12.0
markdown==3.3.6; python_version >= "3.6" markdown==3.3.7
orjson==3.6.5; platform_machine == "x86_64" and python_version >= "3.8" orjson==3.7.5; platform_machine == "x86_64"
protobuf==3.19.3; python_version >= "3.8" protobuf==4.21.2
psutil==5.9.0; (python_version >= "2.6" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0") psutil==5.9.1
pysocks==1.7.1; python_version >= "3.8" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.8" requests==2.28.1
requests==2.27.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0")
send2trash==1.8.0 send2trash==1.8.0
soupsieve==2.3.1; python_full_version > "3.0.0" and python_version >= "3.8" soupsieve==2.3.2.post1
urllib3==1.26.8; python_version >= "3.8" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version < "4" and python_version >= "3.8" stringcase==1.2.0
webob==1.8.7; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.3.0") urllib3==1.26.9
zipp==3.7.0; python_version < "3.10" and python_version >= "3.7" webob==1.8.7
zipp==3.8.0

12
src/setup.py Normal file
View File

@ -0,0 +1,12 @@
from setuptools import setup, find_packages
# TODO: Generate from or parse values from pyproject.toml.
setup(
name="anki-sync-server",
version="2.3.0",
description="Self-hosted Anki Sync Server.",
author="Anki Community",
author_email="kothary.vikash+ankicommunity@gmail.com",
packages=find_packages(),
url='https://ankicommunity.github.io/'
)

View File

@ -3,7 +3,7 @@ import os
import shutil import shutil
import tempfile import tempfile
from anki import Collection from anki.collection import Collection
class CollectionUtils: class CollectionUtils:
""" """