├── requirements.windows.txt ├── requirements.dev.txt ├── configure.sh ├── canvas_grab ├── error.py ├── snapshot │ ├── snapshot.py │ ├── __init__.py │ ├── snapshot_file.py │ ├── snapshot_link.py │ ├── on_disk_snapshot.py │ ├── canvas_file_snapshot.py │ └── canvas_module_snapshot.py ├── course_filter │ ├── all_filter.py │ ├── base_filter.py │ ├── per_filter.py │ ├── term_filter.py │ └── __init__.py ├── __init__.py ├── configurable.py ├── config │ ├── endpoint.py │ ├── organize_mode.py │ └── __init__.py ├── course_parser.py ├── version.py ├── download_file.py ├── utils.py ├── planner.py ├── request_batcher.py ├── file_filter.py ├── __main__.py ├── get_options.py └── transfer.py ├── canvas_grab_gui ├── canvas_grab_shim.py ├── main.pyproject ├── __init__.py ├── ui │ ├── icons │ │ ├── arrow-right-short.svg │ │ ├── info-circle-fill.svg │ │ ├── box-arrow-in-right.svg │ │ └── cloud-check.svg │ ├── main.qml │ ├── Done.qml │ └── InProgress.qml ├── sync_model.py ├── main.py └── main.pyproject.user ├── configure.ps1 ├── docs ├── source │ ├── modules.rst │ ├── canvas_grab.config.rst │ ├── canvas_grab.course_filter.rst │ ├── canvas_grab.snapshot.rst │ └── canvas_grab.rst ├── Makefile ├── make.bat ├── index.rst └── conf.py ├── main.py ├── gui.py ├── requirements.txt ├── .github └── workflows │ └── ci.yml ├── canvas_grab.sh ├── canvas_grab.ps1 ├── LICENSE ├── .gitignore └── README.md /requirements.windows.txt: -------------------------------------------------------------------------------- 1 | win32_setctime 2 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-rtd-theme 3 | -------------------------------------------------------------------------------- /configure.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ./canvas_grab.sh --configure 4 | -------------------------------------------------------------------------------- /canvas_grab/error.py: -------------------------------------------------------------------------------- 1 | class CanvasGrabCliError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /canvas_grab_gui/canvas_grab_shim.py: -------------------------------------------------------------------------------- 1 | import sys 2 | sys.path.append("..") 3 | -------------------------------------------------------------------------------- /canvas_grab_gui/main.pyproject: -------------------------------------------------------------------------------- 1 | { 2 | "files": ["main.py", "main.qml"] 3 | } 4 | -------------------------------------------------------------------------------- /configure.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/pwsh 2 | $ErrorActionPreference = "Stop" 3 | 4 | ./canvas_grab.ps1 --configure 5 | -------------------------------------------------------------------------------- /canvas_grab/snapshot/snapshot.py: -------------------------------------------------------------------------------- 1 | class Snapshot(object): 2 | def get_snapshot(self): 3 | pass 4 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | canvas_grab 2 | =========== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | canvas_grab 8 | -------------------------------------------------------------------------------- /canvas_grab_gui/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import Main 2 | from . import canvas_grab_shim 3 | 4 | __version__ = "2.0.0-alpha" 5 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import canvas_grab 4 | 5 | if __name__ == '__main__': 6 | canvas_grab.__main__.main() 7 | -------------------------------------------------------------------------------- /gui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import canvas_grab_gui 4 | 5 | if __name__ == '__main__': 6 | canvas_grab_gui.Main().main() 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | canvasapi 2 | colorama 3 | requests 4 | tqdm 5 | retrying 6 | toml 7 | packaging 8 | termcolor 9 | questionary 10 | PySide6 -------------------------------------------------------------------------------- /canvas_grab/snapshot/__init__.py: -------------------------------------------------------------------------------- 1 | from .on_disk_snapshot import OnDiskSnapshot 2 | from .canvas_module_snapshot import CanvasModuleSnapshot 3 | from .canvas_file_snapshot import CanvasFileSnapshot 4 | from .snapshot_file import SnapshotFile 5 | from .snapshot_link import SnapshotLink 6 | -------------------------------------------------------------------------------- /canvas_grab/course_filter/all_filter.py: -------------------------------------------------------------------------------- 1 | from .base_filter import BaseFilter 2 | 3 | 4 | class AllFilter(BaseFilter): 5 | """Synchronize all courses. 6 | 7 | ``AllFilter`` returns selected courses as-is. 8 | """ 9 | 10 | def filter_course(self, courses): 11 | return courses 12 | -------------------------------------------------------------------------------- /canvas_grab/__init__.py: -------------------------------------------------------------------------------- 1 | from . import course_filter 2 | from . import config 3 | from . import snapshot 4 | from . import planner 5 | from . import transfer 6 | from . import version 7 | from . import course_parser 8 | from . import get_options 9 | from . import __main__ 10 | 11 | __version__ = version.VERSION 12 | -------------------------------------------------------------------------------- /canvas_grab_gui/ui/icons/arrow-right-short.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /canvas_grab_gui/ui/icons/info-circle-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /canvas_grab/configurable.py: -------------------------------------------------------------------------------- 1 | class Configurable(object): 2 | def to_config(self): 3 | """Serialize self to dict 4 | 5 | Returns: 6 | dict: serialized configuration 7 | """ 8 | return {} 9 | 10 | def from_config(self, config): 11 | """Deserialize a dict to self 12 | 13 | Args: 14 | config (dict): configuration to deserialize 15 | """ 16 | pass 17 | 18 | 19 | class Interactable(object): 20 | def interact(self): 21 | """Get configuration from user input 22 | """ 23 | pass 24 | -------------------------------------------------------------------------------- /canvas_grab_gui/ui/icons/box-arrow-in-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /canvas_grab/course_filter/base_filter.py: -------------------------------------------------------------------------------- 1 | from ..configurable import Configurable, Interactable 2 | 3 | 4 | class BaseFilter(Configurable, Interactable): 5 | def filter_course(self, courses): 6 | """Filter courses 7 | 8 | Args: 9 | courses ([canvasapi.course.Course]): list of courses 10 | Returns: 11 | [canvasapi.course.Course]: list of courses 12 | """ 13 | pass 14 | 15 | def interact(self, courses): 16 | """TUI for asking users what courses to choose from. 17 | 18 | Args: 19 | courses ([canvasapi.course.Course]): list of courses 20 | """ 21 | pass 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 3.8 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: 3.8 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install pycodestyle 25 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 26 | - name: Lint with pep8 27 | run: pycodestyle --ignore=E501 **/*.py 28 | -------------------------------------------------------------------------------- /canvas_grab.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Check Python installation..." 5 | python3 --version 6 | 7 | echo "Check virtual environment..." 8 | 9 | if [ ! -d "venv" ]; then 10 | echo "Create virtual environment..." 11 | python3 -m venv venv 12 | echo "Activate virtual environment..." 13 | . venv/bin/activate 14 | echo "Install dependencies with SJTUG mirror..." 15 | python -m pip install --upgrade pip -i https://mirrors.sjtug.sjtu.edu.cn/pypi/web/simple 16 | python -m pip install -r requirements.txt -i https://mirrors.sjtug.sjtu.edu.cn/pypi/web/simple 17 | else 18 | echo "Activate virtual environment..." 19 | . venv/bin/activate 20 | fi 21 | 22 | python main.py $@ 23 | -------------------------------------------------------------------------------- /docs/source/canvas_grab.config.rst: -------------------------------------------------------------------------------- 1 | canvas\_grab.config package 2 | =========================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | canvas\_grab.config.endpoint module 8 | ----------------------------------- 9 | 10 | .. automodule:: canvas_grab.config.endpoint 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | canvas\_grab.config.organize\_mode module 16 | ----------------------------------------- 17 | 18 | .. automodule:: canvas_grab.config.organize_mode 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | Module contents 24 | --------------- 25 | 26 | .. automodule:: canvas_grab.config 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /canvas_grab_gui/ui/main.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2 | import QtQuick.Window 3 | import QtQuick.Layouts 4 | import QtQuick.Controls 5 | import Qt.labs.qmlmodels 6 | 7 | Window { 8 | id: window 9 | width: 800 10 | height: 600 11 | visible: true 12 | title: qsTr("Canvas Grab") 13 | 14 | ListView { 15 | id: idProgressListView 16 | width: parent.width 17 | height: parent.height 18 | spacing: 10 19 | 20 | model: py_sync_model 21 | delegate: chooser 22 | 23 | DelegateChooser { 24 | id: chooser 25 | role: "status" 26 | DelegateChoice { roleValue: "inProgress"; InProgress {} } 27 | DelegateChoice { roleValue: "done"; Done {} } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /canvas_grab/snapshot/snapshot_file.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class SnapshotFile: 6 | """A snapshot of file, which only includes metadata. 7 | """ 8 | name: str 9 | size: int 10 | modified_at: int 11 | created_at: int = 0 12 | url: str = '' 13 | file_id: int = 0 14 | 15 | 16 | def from_canvas_file(file): 17 | """Create a ``SnapshotFile`` from canvasapi file object 18 | 19 | Args: 20 | file (canvasapi.file.File): File object to use 21 | 22 | Returns: 23 | SnapshotFile: the metadata of file, or so-called `SnapshotFile`. 24 | """ 25 | return SnapshotFile(file.display_name, file.size, int(file.modified_at_date.timestamp()), int(file.created_at_date.timestamp()), file.url, file.id) 26 | -------------------------------------------------------------------------------- /canvas_grab_gui/ui/icons/cloud-check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /canvas_grab/snapshot/snapshot_link.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from html import escape 3 | 4 | 5 | @dataclass 6 | class SnapshotLink: 7 | """A snapshot of link, which only includes metadata. 8 | """ 9 | name: str 10 | url: str = '' 11 | module_name: str = '' 12 | 13 | def content(self): 14 | """Generate HTML content of the link 15 | 16 | Returns: 17 | str: HTML content string 18 | """ 19 | return f''' 20 | 21 | {escape(self.name)} 22 | 23 | 24 | 25 | 26 |

Redirecting you to {escape(self.module_name)} - {escape(self.name)}

27 | 28 | ''' 29 | -------------------------------------------------------------------------------- /canvas_grab.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/pwsh 2 | $ErrorActionPreference = "Stop" 3 | 4 | echo "Check Python installation..." 5 | python --version 6 | 7 | echo "Check virtual environment..." 8 | 9 | if (-Not(Test-Path -Path venv)) { 10 | echo "Create virtual environment..." 11 | python -m venv venv 12 | echo "Activate virtual environment..." 13 | . ./venv/Scripts/Activate.ps1 14 | echo "Install dependencies with SJTUG mirror..." 15 | python -m pip install --upgrade pip -i https://mirrors.sjtug.sjtu.edu.cn/pypi/web/simple 16 | python -m pip install -r requirements.txt -i https://mirrors.sjtug.sjtu.edu.cn/pypi/web/simple 17 | python -m pip install -r requirements.windows.txt -i https://mirrors.sjtug.sjtu.edu.cn/pypi/web/simple 18 | } else { 19 | echo "Activate virtual environment..." 20 | . ./venv/Scripts/Activate.ps1 21 | } 22 | 23 | python main.py $args 24 | 25 | pause 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /canvas_grab_gui/ui/Done.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2 | import QtQuick.Window 3 | import QtQuick.Layouts 4 | import QtQuick.Controls 5 | 6 | 7 | ColumnLayout { 8 | width: idProgressListView.width 9 | 10 | RowLayout { 11 | Image { 12 | width: 30 13 | height: 30 14 | source: "icons/" + iconName + ".svg" 15 | sourceSize: Qt.size(30, 30) 16 | Layout.leftMargin: 10 17 | Layout.rightMargin: 10 18 | Layout.topMargin: 5 19 | Layout.bottomMargin: 5 20 | } 21 | Text { 22 | text: name 23 | font.pointSize: 20 24 | verticalAlignment: Text.AlignVCenter 25 | } 26 | } 27 | 28 | RowLayout { 29 | Text { 30 | text: progressText 31 | verticalAlignment: Text.AlignVCenter 32 | font.pointSize: 16 33 | Layout.leftMargin: 10 34 | Layout.rightMargin: 10 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /canvas_grab/config/endpoint.py: -------------------------------------------------------------------------------- 1 | from ..configurable import Configurable, Interactable 2 | from canvasapi import Canvas 3 | import questionary 4 | 5 | 6 | class Endpoint(Configurable, Interactable): 7 | """Endpoint stores Canvas LMS endpoint and API key. 8 | """ 9 | 10 | def __init__(self): 11 | self.endpoint = 'https://oc.sjtu.edu.cn' 12 | self.api_key = '' 13 | 14 | def to_config(self): 15 | return { 16 | 'endpoint': self.endpoint, 17 | 'api_key': self.api_key 18 | } 19 | 20 | def from_config(self, config): 21 | self.endpoint = config['endpoint'] 22 | self.api_key = config['api_key'] 23 | 24 | def interact(self): 25 | self.endpoint = questionary.text( 26 | 'Canvas API endpoint', default=self.endpoint).unsafe_ask() 27 | self.api_key = questionary.text( 28 | 'API Key', default=self.api_key, instruction="Please visit profile page of Canvas LMS to generate an access token").unsafe_ask() 29 | 30 | def login(self): 31 | return Canvas(self.endpoint, self.api_key) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alex Chi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/source/canvas_grab.course_filter.rst: -------------------------------------------------------------------------------- 1 | canvas\_grab.course\_filter package 2 | =================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | canvas\_grab.course\_filter.all\_filter module 8 | ---------------------------------------------- 9 | 10 | .. automodule:: canvas_grab.course_filter.all_filter 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | canvas\_grab.course\_filter.base\_filter module 16 | ----------------------------------------------- 17 | 18 | .. automodule:: canvas_grab.course_filter.base_filter 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | canvas\_grab.course\_filter.per\_filter module 24 | ---------------------------------------------- 25 | 26 | .. automodule:: canvas_grab.course_filter.per_filter 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | canvas\_grab.course\_filter.term\_filter module 32 | ----------------------------------------------- 33 | 34 | .. automodule:: canvas_grab.course_filter.term_filter 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | Module contents 40 | --------------- 41 | 42 | .. automodule:: canvas_grab.course_filter 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | -------------------------------------------------------------------------------- /canvas_grab/snapshot/on_disk_snapshot.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from .snapshot_file import SnapshotFile 3 | from .snapshot import Snapshot 4 | 5 | 6 | class OnDiskSnapshot(Snapshot): 7 | """Take an on-disk snapshot. 8 | 9 | This snapshot-taker will scan all files inside a folder. On Windows, backslash will be 10 | replaced by slash. 11 | """ 12 | 13 | def __init__(self, base_path): 14 | """Create an on-disk snapshot-taker 15 | 16 | Args: 17 | base_path (str): Base path of the snapshot 18 | """ 19 | self.base_path = base_path 20 | self.snapshot = {} 21 | 22 | def take_snapshot(self): 23 | """Take an on-disk snapshot 24 | 25 | Returns: 26 | dict: snapshot on disk. All objects are of type `SnapshotFile`. 27 | """ 28 | base = Path(self.base_path) 29 | for item in base.rglob('*'): 30 | if item.is_file() and not item.name.startswith('.') and not item.name.endswith('.canvas_tmp'): 31 | stat = item.stat() 32 | self.snapshot[item.relative_to(base).as_posix()] = SnapshotFile( 33 | item.name, stat.st_size, int(stat.st_mtime)) 34 | return self.snapshot 35 | 36 | def get_snapshot(self): 37 | """Get the previously-taken snapshot 38 | 39 | Returns: 40 | dict: snapshot of Canvas 41 | """ 42 | return self.snapshot 43 | -------------------------------------------------------------------------------- /canvas_grab_gui/ui/InProgress.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2 | import QtQuick.Window 3 | import QtQuick.Layouts 4 | import QtQuick.Controls 5 | 6 | ColumnLayout { 7 | width: idProgressListView.width 8 | 9 | RowLayout { 10 | BusyIndicator { 11 | width: 30 12 | height: 30 13 | Layout.leftMargin: 5 14 | Layout.rightMargin: 5 15 | } 16 | Text { 17 | text: name 18 | font.pointSize: 20 19 | verticalAlignment: Text.AlignVCenter 20 | } 21 | } 22 | 23 | RowLayout { 24 | Text { 25 | text: statusText 26 | verticalAlignment: Text.AlignVCenter 27 | font.pointSize: 16 28 | Layout.leftMargin: 10 29 | Layout.rightMargin: 10 30 | } 31 | ProgressBar { 32 | id: bar 33 | value: progress 34 | Layout.fillWidth: true 35 | Layout.rightMargin: 10 36 | } 37 | } 38 | 39 | RowLayout { 40 | Image { 41 | width: 30 42 | height: 30 43 | source: "icons/info-circle-fill.svg" 44 | sourceSize: Qt.size(16, 16) 45 | Layout.leftMargin: 10 46 | } 47 | Text { 48 | text: progressText 49 | color: "steelblue" 50 | font.pointSize: 16 51 | verticalAlignment: Text.AlignVCenter 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /canvas_grab/course_parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from .utils import normalize_path, file_regex 3 | 4 | 5 | class CourseParser(object): 6 | def get_parsed_name(self, course): 7 | r = re.search( 8 | r"\((?P[0-9\-]+)\)-(?P[A-Za-z0-9]+)-(?P.+)-(?P.+)\Z", course.course_code) 9 | if r is not None: 10 | r = r.groupdict() 11 | else: 12 | return normalize_path(course.name) 13 | 14 | if hasattr(course, 'original_name'): 15 | course_name = course.original_name 16 | course_nickname = course.name 17 | else: 18 | course_name = course.name 19 | course_nickname = course.name 20 | 21 | template_map = { 22 | r"{CANVAS_ID}": str(course.id), 23 | r"{SJTU_ID}": r.get("sjtu_id", ""), 24 | r"{SEMESTER_ID}": r.get("semester_id", ""), 25 | r"{CLASSROOM_ID}": r.get("classroom_id", ""), 26 | r"{NAME}": normalize_path(course_name.replace("(", "(").replace(")", ")"), file_regex), 27 | r"{NICKNAME}": normalize_path(course_nickname.replace("(", "(").replace(")", ")"), file_regex), 28 | r"{COURSE_CODE}": course.course_code 29 | } 30 | 31 | folder_name = '{SJTU_ID}-{NAME}' 32 | for old, new in template_map.items(): 33 | folder_name = folder_name.replace(old, new) 34 | 35 | folder_name = normalize_path(folder_name) 36 | return folder_name 37 | -------------------------------------------------------------------------------- /canvas_grab/course_filter/per_filter.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | from .base_filter import BaseFilter 3 | from ..utils import group_by, summarize_courses 4 | 5 | 6 | class PerFilter(BaseFilter): 7 | """Filter single courses. 8 | 9 | ``PerFilter`` filters every single courses as selected by user. IDs of courses 10 | will be stored in a list. 11 | """ 12 | 13 | def __init__(self): 14 | self.course_id = [] 15 | 16 | def filter_course(self, courses): 17 | return list(filter(lambda course: course.id in self.course_id, courses)) 18 | 19 | def to_config(self): 20 | return { 21 | 'course_id': self.course_id 22 | } 23 | 24 | def from_config(self, config): 25 | self.course_id = config['course_id'] 26 | 27 | def interact(self, courses): 28 | choices = [] 29 | sorted_courses = sorted( 30 | courses, key=lambda course: course.enrollment_term_id) 31 | sorted_courses.reverse() 32 | for course in sorted_courses: 33 | choices.append(questionary.Choice( 34 | f'{course.name} (Term {course.enrollment_term_id})', 35 | course.id, 36 | checked=course.id in self.course_id 37 | )) 38 | while True: 39 | self.course_id = questionary.checkbox( 40 | 'Select courses to download', 41 | choices).unsafe_ask() 42 | if len(self.course_id) == 0: 43 | print('At least one course must be selected.') 44 | else: 45 | break 46 | -------------------------------------------------------------------------------- /canvas_grab/version.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from colorama import Back, Fore, Style 3 | from packaging import version as ver_parser 4 | from termcolor import colored 5 | 6 | GITHUB_RELEASE_URL = "https://api.github.com/repos/skyzh/canvas_grab/releases/latest" 7 | VERSION = "2.2.1-alpha" 8 | 9 | 10 | def check_latest_version(): 11 | version_obj = {} 12 | print() 13 | try: 14 | version_obj = requests.get(GITHUB_RELEASE_URL, timeout=3).json() 15 | except Exception as e: 16 | print(f"{colored('Failed to check update.', 'red')} It's normal if you don't have a stable network connection.") 17 | print(f"You may report the following message to developer: {e}") 18 | return 19 | version = version_obj.get("tag_name", "unknown") 20 | if version == "unknown": 21 | print("Failed to check update: unknown remote version") 22 | elif ver_parser.parse(version) > ver_parser.parse(VERSION): 23 | print(f"You're using version {colored(VERSION, 'green')}, " 24 | f"but the latest release is {colored(version, 'green')}.") 25 | print(f"Please visit {colored('https://github.com/skyzh/canvas_grab/releases', 'blue')} " 26 | "to download the latest version.") 27 | print() 28 | print(version_obj.get("body", "")) 29 | print() 30 | elif ver_parser.parse(version) < ver_parser.parse(VERSION): 31 | print("Just checked update. You're using development version of canvas_grab. :)") 32 | else: 33 | print("Just checked update. You're using latest version of canvas_grab. :)") 34 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | canvas_grab: synchronize files from Canvas 2 | =========================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | :caption: Contents: 7 | 8 | source/modules 9 | source/canvas_grab.rst 10 | source/canvas_grab.config.rst 11 | source/canvas_grab.course_filter.rst 12 | source/canvas_grab.snapshot.rst 13 | 14 | Introduction 15 | ============ 16 | 17 | 18 | Canvas Grab is a one-click script to synchronize files from Canvas LMS. 19 | See repository hosted on GitHub 20 | `skyzh/canvas_grab `_ 21 | for installation and usage guide. For how canvas_grab works 22 | internally, you may start with this documentation site. 23 | 24 | How Canvas Grab Works 25 | ====================== 26 | 27 | Canvas Grab provides an easy-to-use TUI for tweaking configurations. 28 | All configuration items could be set in command-line, and then be 29 | serialized to ``config.toml``. 30 | 31 | After the configuration phase, Canvas Grab starts synchronizing files 32 | from Canvas LMS course-by-course. For each course, Canvas Grab takes 33 | a snapshot of remote files and local files, which contains metadata of 34 | each object (size, name, modified time, etc.). Then, two snapshots will 35 | be fed into transfer planner, which decides what file to transfer and 36 | what file to delete. Finally, Canvas Grab begins the transfer, which 37 | downloads files from Canvas LMS and delete stale local files. 38 | 39 | You may refer to ``canvas_grab`` module for more information. 40 | 41 | Indices and tables 42 | ================== 43 | 44 | * :ref:`genindex` 45 | * :ref:`modindex` 46 | * :ref:`search` 47 | -------------------------------------------------------------------------------- /canvas_grab/download_file.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import sys 3 | import time 4 | import requests 5 | from tqdm import tqdm 6 | from .utils import is_windows 7 | 8 | 9 | def current_milli_time(): 10 | return round(time.time() * 1000) 11 | 12 | 13 | def download_file(url, desc, filename, file_size, verbose=False, req_timeout=(5, None)): 14 | with requests.get(url, stream=True, timeout=req_timeout) as r: 15 | r.raise_for_status() 16 | chunk_size = 1024 17 | if verbose: 18 | print("size = %d, url = %s" % (file_size, url)) 19 | download_size = 0 20 | 21 | with open(filename + '.canvas_tmp', 'wb') as fp: 22 | with tqdm( 23 | total=file_size, unit='B', 24 | unit_scale=True, 25 | unit_divisor=1024, 26 | desc=desc, bar_format='{l_bar}{bar}{r_bar}', ascii=is_windows(), 27 | leave=False 28 | ) as pbar: 29 | lst_update = current_milli_time() 30 | for chunk in r.iter_content(chunk_size=chunk_size): 31 | fp.write(chunk) 32 | download_size += len(chunk) 33 | current_time = current_milli_time() 34 | if current_time - lst_update > 100: 35 | yield float(download_size) / file_size 36 | lst_update = current_time 37 | pbar.update(len(chunk)) 38 | if download_size != file_size: 39 | raise Exception( 40 | f"Incomplete file: expected {file_size}, downloaded {download_size}") 41 | os.replace(filename + '.canvas_tmp', filename) 42 | return 43 | -------------------------------------------------------------------------------- /docs/source/canvas_grab.snapshot.rst: -------------------------------------------------------------------------------- 1 | canvas\_grab.snapshot package 2 | ============================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | canvas\_grab.snapshot.canvas\_file\_snapshot module 8 | --------------------------------------------------- 9 | 10 | .. automodule:: canvas_grab.snapshot.canvas_file_snapshot 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | canvas\_grab.snapshot.canvas\_module\_snapshot module 16 | ----------------------------------------------------- 17 | 18 | .. automodule:: canvas_grab.snapshot.canvas_module_snapshot 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | canvas\_grab.snapshot.on\_disk\_snapshot module 24 | ----------------------------------------------- 25 | 26 | .. automodule:: canvas_grab.snapshot.on_disk_snapshot 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | canvas\_grab.snapshot.snapshot module 32 | ------------------------------------- 33 | 34 | .. automodule:: canvas_grab.snapshot.snapshot 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | canvas\_grab.snapshot.snapshot\_file module 40 | ------------------------------------------- 41 | 42 | .. automodule:: canvas_grab.snapshot.snapshot_file 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | canvas\_grab.snapshot.snapshot\_link module 48 | ------------------------------------------- 49 | 50 | .. automodule:: canvas_grab.snapshot.snapshot_link 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | Module contents 56 | --------------- 57 | 58 | .. automodule:: canvas_grab.snapshot 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | -------------------------------------------------------------------------------- /canvas_grab/utils.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | import os 3 | import re 4 | 5 | 6 | def group_by(items, predicate): 7 | groups = {} 8 | for item in items: 9 | key = predicate(item) 10 | all_objs = groups.get(key, []) 11 | all_objs.append(item) 12 | groups[key] = all_objs 13 | return groups 14 | 15 | 16 | def summarize_courses(courses, number=5): 17 | joins = [] 18 | for idx, course in enumerate(courses): 19 | if idx >= number: 20 | break 21 | joins.append(course.name) 22 | if len(courses) > number: 23 | joins.append('...') 24 | return ", ".join(joins) + f" ({len(courses)} courses)" 25 | 26 | 27 | def filter_available_courses(courses): 28 | available_courses = [] 29 | not_available_courses = [] 30 | for course in courses: 31 | if hasattr(course, 'name'): 32 | available_courses.append(course) 33 | else: 34 | not_available_courses.append(course) 35 | return available_courses, not_available_courses 36 | 37 | 38 | file_regex = r"[\t\\\/:\*\?\"<>\|]" 39 | path_regex = r"[\t:*?\"<>|\/]" 40 | 41 | 42 | def is_windows(): 43 | return os.name == "nt" 44 | 45 | 46 | if is_windows(): 47 | from win32_setctime import setctime 48 | 49 | 50 | def apply_datetime_attr(path, c_time: int, m_time: int): 51 | a_time = time() 52 | if is_windows(): 53 | setctime(path, c_time) 54 | os.utime(path, (a_time, m_time)) 55 | 56 | 57 | def normalize_path(filename, regex=path_regex): 58 | return re.sub(regex, '_', filename) 59 | 60 | 61 | def truncate_name(name, length=40): 62 | if len(name) > length: 63 | return name[:length - 3] + "..." 64 | else: 65 | return name 66 | 67 | 68 | def find_choice(choices, value): 69 | for choice in choices: 70 | if choice.value == value: 71 | return choice 72 | return None 73 | -------------------------------------------------------------------------------- /canvas_grab/planner.py: -------------------------------------------------------------------------------- 1 | from .snapshot import SnapshotFile, SnapshotLink 2 | 3 | 4 | class Planner(object): 5 | """Planner generates a transfer plan from two snapshots 6 | """ 7 | 8 | def __init__(self, remove_local_file): 9 | self.remove_local_file = remove_local_file 10 | 11 | def plan(self, snapshot_from, snapshot_to, file_filter): 12 | """plan a transfer 13 | 14 | Args: 15 | snapshot_from (dict): source snapshot 16 | snapshot_to (dict): target snapshot 17 | file_filter (canvas_grab.file_filter.FileFilter): file filter 18 | 19 | Returns: 20 | transfer plan 21 | """ 22 | snapshot_from_filter = file_filter.filter_files(snapshot_from) 23 | 24 | plans = [] 25 | # Add and update files 26 | for key, from_item in snapshot_from.items(): 27 | if key not in snapshot_from_filter: 28 | plans.append(('ignore', key, from_item)) 29 | elif key not in snapshot_to: 30 | plans.append(('add', key, from_item)) 31 | else: 32 | to_item = snapshot_to[key] 33 | if isinstance(from_item, SnapshotFile): 34 | if to_item.size != from_item.size or to_item.modified_at != from_item.modified_at: 35 | plans.append(('update', key, from_item)) 36 | if isinstance(from_item, SnapshotLink): 37 | content_length = len(from_item.content().encode('utf-8')) 38 | if to_item.size != content_length: 39 | plans.append(('update', key, from_item)) 40 | for key, to_item in snapshot_to.items(): 41 | if key not in snapshot_from_filter: 42 | if self.remove_local_file: 43 | plans.append(('delete', key, to_item)) 44 | else: 45 | plans.append(('try-remove', key, to_item)) 46 | return plans 47 | -------------------------------------------------------------------------------- /canvas_grab/request_batcher.py: -------------------------------------------------------------------------------- 1 | class RequestBatcher: 2 | """RequestBatcher automatically batches requests with batch API 3 | """ 4 | 5 | def __init__(self, course): 6 | self.course = course 7 | self.cache = {} 8 | 9 | def get_tabs(self): 10 | if 'tabs' not in self.cache: 11 | self.cache['tabs'] = [tab.id for tab in self.course.get_tabs()] 12 | 13 | return self.cache['tabs'] 14 | 15 | def get_files(self): 16 | if 'files' not in self.get_tabs(): 17 | return None 18 | 19 | if 'files' not in self.cache: 20 | self.cache['files'] = { 21 | file.id: file 22 | for file in self.course.get_files() 23 | } 24 | 25 | return self.cache['files'] 26 | 27 | def get_folders(self): 28 | if 'files' not in self.get_tabs(): 29 | return None 30 | 31 | if 'folders' not in self.cache: 32 | self.cache['folders'] = { 33 | folder.id: folder 34 | for folder in self.course.get_folders() 35 | } 36 | 37 | return self.cache['folders'] 38 | 39 | def get_file(self, file_id): 40 | files = self.get_files() 41 | if files is None: 42 | return self.course.get_file(file_id) 43 | else: 44 | return files.get(file_id, self.course.get_file(file_id)) 45 | 46 | def get_modules(self): 47 | if 'modules' not in self.get_tabs(): 48 | return None 49 | 50 | if 'modules' not in self.cache: 51 | self.cache['modules'] = { 52 | module.id: module 53 | for module in self.course.get_modules() 54 | } 55 | 56 | return self.cache['modules'] 57 | 58 | def get_pages(self): 59 | if 'pages' not in self.get_tabs(): 60 | return None 61 | 62 | if 'pages' not in self.cache: 63 | self.cache['pages'] = list(self.course.get_pages()) 64 | 65 | return self.cache['pages'] 66 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('../../canvas_grab/')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'canvas_grab' 21 | copyright = '2021, Alex Chi' 22 | author = 'Alex Chi' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = 'master' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc' 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This pattern also affects html_static_path and html_extra_path. 43 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 44 | 45 | 46 | # -- Options for HTML output ------------------------------------------------- 47 | 48 | # The theme to use for HTML and HTML Help pages. See the documentation for 49 | # a list of builtin themes. 50 | # 51 | html_theme = 'sphinx_rtd_theme' 52 | 53 | # Add any paths that contain custom static files (such as style sheets) here, 54 | # relative to this directory. They are copied after the builtin static files, 55 | # so a file named "default.css" will overwrite the builtin "default.css". 56 | html_static_path = ['_static'] 57 | -------------------------------------------------------------------------------- /canvas_grab/course_filter/term_filter.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | from .base_filter import BaseFilter 3 | from ..utils import group_by, summarize_courses 4 | 5 | 6 | class TermFilter(BaseFilter): 7 | """Filter courses by term. 8 | 9 | ``TermFilter`` selects all courses from selected terms. The allowed terms IDs are stored 10 | in a list. If `terms == [-1]`, then only the latest term will be selected. 11 | """ 12 | 13 | def __init__(self): 14 | self.terms = [-1] 15 | 16 | def filter_course(self, courses): 17 | terms = self.terms.copy() 18 | if -1 in terms: 19 | terms = [ 20 | max(map(lambda course: course.enrollment_term_id, courses))] 21 | print(f'TermFilter: Select latest term {terms[0]}') 22 | 23 | return list(filter(lambda course: course.enrollment_term_id in terms, courses)) 24 | 25 | def to_config(self): 26 | return { 27 | 'terms': self.terms 28 | } 29 | 30 | def from_config(self, config): 31 | self.terms = config['terms'] 32 | 33 | def interact(self, courses): 34 | groups = group_by(courses, lambda course: course.enrollment_term_id) 35 | choices = [] 36 | for (term, courses) in groups.items(): 37 | choices.append( 38 | questionary.Choice( 39 | f'Term {term}: {summarize_courses(courses)}', 40 | term, 41 | checked=term in self.terms 42 | ) 43 | ) 44 | choices = sorted(choices, key=lambda choice: choice.value) 45 | choices.append( 46 | questionary.Choice( 47 | 'Latest term only', 48 | -1, 49 | checked=-1 in self.terms 50 | ) 51 | ) 52 | choices.reverse() 53 | while True: 54 | self.terms = questionary.checkbox( 55 | 'Select terms to download', 56 | choices 57 | ).unsafe_ask() 58 | if len(self.terms) == 0: 59 | print('At least one term must be selected.') 60 | elif -1 in self.terms and len(self.terms) != 1: 61 | print('Invalid choice') 62 | else: 63 | break 64 | -------------------------------------------------------------------------------- /docs/source/canvas_grab.rst: -------------------------------------------------------------------------------- 1 | canvas\_grab package 2 | ==================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | canvas_grab.config 11 | canvas_grab.course_filter 12 | canvas_grab.snapshot 13 | 14 | Submodules 15 | ---------- 16 | 17 | canvas\_grab.configurable module 18 | -------------------------------- 19 | 20 | .. automodule:: canvas_grab.configurable 21 | :members: 22 | :undoc-members: 23 | :show-inheritance: 24 | 25 | canvas\_grab.course\_parser module 26 | ---------------------------------- 27 | 28 | .. automodule:: canvas_grab.course_parser 29 | :members: 30 | :undoc-members: 31 | :show-inheritance: 32 | 33 | canvas\_grab.download\_file module 34 | ---------------------------------- 35 | 36 | .. automodule:: canvas_grab.download_file 37 | :members: 38 | :undoc-members: 39 | :show-inheritance: 40 | 41 | canvas\_grab.error module 42 | ------------------------- 43 | 44 | .. automodule:: canvas_grab.error 45 | :members: 46 | :undoc-members: 47 | :show-inheritance: 48 | 49 | canvas\_grab.file\_filter module 50 | -------------------------------- 51 | 52 | .. automodule:: canvas_grab.file_filter 53 | :members: 54 | :undoc-members: 55 | :show-inheritance: 56 | 57 | canvas\_grab.planner module 58 | --------------------------- 59 | 60 | .. automodule:: canvas_grab.planner 61 | :members: 62 | :undoc-members: 63 | :show-inheritance: 64 | 65 | canvas\_grab.request\_batcher module 66 | ------------------------------------ 67 | 68 | .. automodule:: canvas_grab.request_batcher 69 | :members: 70 | :undoc-members: 71 | :show-inheritance: 72 | 73 | canvas\_grab.transfer module 74 | ---------------------------- 75 | 76 | .. automodule:: canvas_grab.transfer 77 | :members: 78 | :undoc-members: 79 | :show-inheritance: 80 | 81 | canvas\_grab.utils module 82 | ------------------------- 83 | 84 | .. automodule:: canvas_grab.utils 85 | :members: 86 | :undoc-members: 87 | :show-inheritance: 88 | 89 | canvas\_grab.version module 90 | --------------------------- 91 | 92 | .. automodule:: canvas_grab.version 93 | :members: 94 | :undoc-members: 95 | :show-inheritance: 96 | 97 | Module contents 98 | --------------- 99 | 100 | .. automodule:: canvas_grab 101 | :members: 102 | :undoc-members: 103 | :show-inheritance: 104 | -------------------------------------------------------------------------------- /canvas_grab/course_filter/__init__.py: -------------------------------------------------------------------------------- 1 | from .all_filter import AllFilter 2 | from .term_filter import TermFilter 3 | from .base_filter import BaseFilter 4 | from .per_filter import PerFilter 5 | from ..configurable import Configurable, Interactable 6 | import questionary 7 | 8 | 9 | def get_name(course_filter): 10 | if isinstance(course_filter, TermFilter): 11 | return 'term' 12 | if isinstance(course_filter, AllFilter): 13 | return 'all' 14 | if isinstance(course_filter, PerFilter): 15 | return 'per' 16 | return '' 17 | 18 | 19 | class CourseFilter(Configurable, Interactable): 20 | """Configures ``CourseFilter`` for Canvas Grab. 21 | 22 | Current filter will be stored in ``filter_name``. Filter configurations are 23 | stored separately in their corresponding sections in config file. 24 | """ 25 | 26 | def __init__(self): 27 | self.filter_name = 'all' 28 | self.all_filter = AllFilter() 29 | self.term_filter = TermFilter() 30 | self.per_filter = PerFilter() 31 | 32 | def get_filter(self): 33 | if self.filter_name == 'term': 34 | return self.term_filter 35 | if self.filter_name == 'all': 36 | return self.all_filter 37 | if self.filter_name == 'per': 38 | return self.per_filter 39 | return None 40 | 41 | def to_config(self): 42 | return { 43 | 'filter_name': self.filter_name, 44 | 'all_filter': self.all_filter.to_config(), 45 | 'term_filter': self.term_filter.to_config(), 46 | 'per_filter': self.per_filter.to_config() 47 | } 48 | 49 | def from_config(self, config): 50 | self.filter_name = config['filter_name'] 51 | self.all_filter.from_config(config['all_filter']) 52 | self.term_filter.from_config(config['term_filter']) 53 | self.per_filter.from_config(config['per_filter']) 54 | 55 | def interact(self, courses): 56 | choices = [ 57 | questionary.Choice('All courses', 'all'), 58 | questionary.Choice('Filter by term', 'term'), 59 | questionary.Choice('Select individual courses', 'per') 60 | ] 61 | current_id = ['all', 'term', 'per'].index(self.filter_name) 62 | self.filter_name = questionary.select( 63 | 'Select course filter mode', 64 | choices, 65 | default=choices[current_id] 66 | ).unsafe_ask() 67 | self.get_filter().interact(courses) 68 | -------------------------------------------------------------------------------- /canvas_grab/file_filter.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | from .configurable import Configurable 3 | from .utils import find_choice 4 | from .snapshot import SnapshotLink 5 | 6 | FILE_GROUP = { 7 | 'Video': [".mp4", ".avi", ".mkv"], 8 | 'Audio': [".mp3", ".wav", ".aac", ".flac"], 9 | 'Image': [".bmp", ".jpg", ".jpeg", ".png", ".gif"], 10 | 'Document': [".ppt", ".pptx", ".doc", ".docx", 11 | ".xls", ".xlsx", ".pdf", ".epub", ".caj"] 12 | } 13 | 14 | 15 | class FileFilter(Configurable): 16 | 17 | def __init__(self): 18 | self.allowed_group = ['Image', 'Document'] 19 | self.allowed_extra = [] 20 | 21 | def allowed_extensions(self): 22 | exts = [] 23 | for group in self.allowed_group: 24 | exts.extend(FILE_GROUP[group]) 25 | exts.extend(self.allowed_extra) 26 | return exts 27 | 28 | def filter_files(self, snapshot): 29 | if 'All' in self.allowed_group: 30 | return snapshot 31 | allowed = self.allowed_extensions() 32 | return {k: v for k, v in snapshot.items() if any(map(lambda ext: k.endswith(ext), allowed)) or isinstance(v, SnapshotLink)} 33 | 34 | def to_config(self): 35 | return { 36 | 'allowed_group': self.allowed_group, 37 | 'allowed_extra': self.allowed_extra 38 | } 39 | 40 | def from_config(self, config): 41 | self.allowed_group = config['allowed_group'] 42 | self.allowed_extra = config['allowed_extra'] 43 | 44 | def interact(self): 45 | choices = [] 46 | for key, group in FILE_GROUP.items(): 47 | choices.append(questionary.Choice( 48 | f'{key} ({", ".join(group)})', 49 | key, 50 | checked=key in self.allowed_group 51 | )) 52 | choices.append(questionary.Choice( 53 | f'Allow all', 54 | 'All', 55 | checked='All' in self.allowed_group 56 | )) 57 | choices.append(questionary.Choice( 58 | f'Custom', 59 | 'custom', 60 | disabled='Please set extra allowed extensions in `allowed_extra` config' 61 | )) 62 | while True: 63 | self.allowed_group = questionary.checkbox( 64 | 'Select allowed extensions', 65 | choices).unsafe_ask() 66 | if len(self.allowed_group) == 0: 67 | print('At least one extension group must be selected.') 68 | elif 'All' in self.allowed_group and len(self.allowed_group) != 1: 69 | print('Invalid choice.') 70 | else: 71 | break 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | .checkpoint 138 | files/ 139 | config.toml 140 | .vscode/ 141 | download_video.sh 142 | download_video.ps1 143 | 144 | # PyCharm 145 | .idea/ 146 | -------------------------------------------------------------------------------- /canvas_grab/config/organize_mode.py: -------------------------------------------------------------------------------- 1 | import questionary 2 | from ..configurable import Configurable, Interactable 3 | from ..utils import find_choice 4 | from ..snapshot import CanvasFileSnapshot, CanvasModuleSnapshot 5 | from ..error import CanvasGrabCliError 6 | 7 | 8 | class OrganizeMode(Configurable, Interactable): 9 | """OrganizeMode decides how data are stored on disk. 10 | 11 | Currently, there are four modes: module (with link) and 12 | as-is (with link). 13 | """ 14 | 15 | def __init__(self): 16 | self.mode = 'module' 17 | self.delete_file = False 18 | 19 | def get_snapshots(self, course): 20 | if self.mode == 'module_link': 21 | canvas_snapshot_module = CanvasModuleSnapshot( 22 | course, True) 23 | else: 24 | canvas_snapshot_module = CanvasModuleSnapshot( 25 | course) 26 | 27 | if self.mode == 'file_link': 28 | canvas_snapshot_file = CanvasFileSnapshot(course, True) 29 | else: 30 | canvas_snapshot_file = CanvasFileSnapshot(course) 31 | 32 | if self.mode == 'module' or self.mode == 'module_link': 33 | canvas_snapshots = [canvas_snapshot_module, canvas_snapshot_file] 34 | elif self.mode == 'file' or self.mode == 'file_link': 35 | canvas_snapshots = [canvas_snapshot_file, canvas_snapshot_module] 36 | else: 37 | raise CanvasGrabCliError(f"Unsupported organize mode {self.mode}") 38 | 39 | return self.mode, canvas_snapshots 40 | 41 | def to_config(self): 42 | return { 43 | 'mode': self.mode, 44 | 'delete_file': self.delete_file 45 | } 46 | 47 | def from_config(self, config): 48 | self.mode = config['mode'] 49 | self.delete_file = config['delete_file'] 50 | 51 | def interact(self): 52 | choices = [ 53 | questionary.Choice( 54 | 'Organize by module, only download files', 'module'), 55 | questionary.Choice( 56 | 'Organize by module, download files, links and pages', 'module_link'), 57 | questionary.Choice( 58 | 'As-is in file list', 'file'), 59 | questionary.Choice( 60 | 'As-is in file list, plus pages', 'file_link'), 61 | questionary.Choice('Custom', 'custom', 62 | disabled='not supported yet') 63 | ] 64 | self.mode = questionary.select( 65 | 'Select default file organization mode', 66 | choices, 67 | default=find_choice(choices, self.mode) 68 | ).unsafe_ask() 69 | choices = [ 70 | questionary.Choice( 71 | "Delete local files if they disappears on Canvas", True), 72 | questionary.Choice("Always keep local files", False) 73 | ] 74 | self.delete_file = questionary.select( 75 | 'How to handle deleted files on Canvas', 76 | choices, 77 | default=find_choice(choices, self.delete_file) 78 | ).unsafe_ask() 79 | -------------------------------------------------------------------------------- /canvas_grab/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from colorama import init 4 | import canvas_grab 5 | from pathlib import Path 6 | from termcolor import colored 7 | from canvasapi.exceptions import ResourceDoesNotExist 8 | import sys 9 | 10 | 11 | def main(): 12 | init() 13 | # Welcome users, and load configurations. 14 | try: 15 | interactive, noupdate, config = canvas_grab.get_options.get_options() 16 | except TypeError: 17 | # User canceled the configuration process 18 | return 19 | 20 | # Finally, log in and start synchronize 21 | canvas = config.endpoint.login() 22 | print(f'You are logged in as {colored(canvas.get_current_user(), "cyan")}') 23 | 24 | courses = list(canvas.get_courses()) 25 | available_courses, not_available = canvas_grab.utils.filter_available_courses( 26 | courses) 27 | filtered_courses = config.course_filter.get_filter().filter_course( 28 | available_courses) 29 | 30 | total_course_count = len(courses) 31 | not_available_count = len(not_available) 32 | filtered_count = len(available_courses) - len(filtered_courses) 33 | print(colored( 34 | f'{total_course_count} courses in total, {not_available_count} not available, {filtered_count} filtered', 'cyan')) 35 | 36 | course_name_parser = canvas_grab.course_parser.CourseParser() 37 | for idx, course in enumerate(filtered_courses): 38 | course_name = course.name 39 | print( 40 | f'({idx+1}/{len(filtered_courses)}) Course {colored(course_name, "cyan")} (ID: {course.id})') 41 | # take on-disk snapshot 42 | parsed_name = course_name_parser.get_parsed_name(course) 43 | print(f' Download to {colored(parsed_name, "cyan")}') 44 | on_disk_path = f'{config.download_folder}/{parsed_name}' 45 | on_disk_snapshot = canvas_grab.snapshot.OnDiskSnapshot( 46 | on_disk_path).take_snapshot() 47 | 48 | # take canvas snapshot 49 | mode, canvas_snapshots = config.organize_mode.get_snapshots(course) 50 | canvas_snapshot = {} 51 | for canvas_snapshot_obj in canvas_snapshots: 52 | try: 53 | canvas_snapshot = canvas_snapshot_obj.take_snapshot() 54 | except ResourceDoesNotExist: 55 | print( 56 | colored(f'{mode} not supported, falling back to alternative mode', 'yellow')) 57 | continue 58 | break 59 | 60 | # generate transfer plan 61 | planner = canvas_grab.planner.Planner(config.organize_mode.delete_file) 62 | plans = planner.plan( 63 | canvas_snapshot, on_disk_snapshot, config.file_filter) 64 | print(colored( 65 | f' Updating {len(plans)} objects ({len(canvas_snapshot)} remote objects -> {len(on_disk_snapshot)} local objects)')) 66 | # start download 67 | transfer = canvas_grab.transfer.Transfer() 68 | transfer.transfer( 69 | on_disk_path, f'{config.download_folder}/_canvas_grab_archive', plans) 70 | 71 | if not noupdate: 72 | canvas_grab.version.check_latest_version() 73 | 74 | 75 | if __name__ == '__main__': 76 | main() 77 | -------------------------------------------------------------------------------- /canvas_grab/config/__init__.py: -------------------------------------------------------------------------------- 1 | from ..configurable import Configurable, Interactable 2 | from canvasapi import Canvas 3 | from canvasapi.exceptions import InvalidAccessToken 4 | from termcolor import colored 5 | 6 | from .endpoint import Endpoint 7 | from .organize_mode import OrganizeMode 8 | from ..course_filter import CourseFilter 9 | from ..file_filter import FileFilter 10 | from ..utils import filter_available_courses 11 | 12 | 13 | class Config(Configurable, Interactable): 14 | """Config stores all configurations used by Canvas Grab. 15 | """ 16 | 17 | def __init__(self): 18 | self.endpoint = Endpoint() 19 | self.course_filter = CourseFilter() 20 | self.organize_mode = OrganizeMode() 21 | self.download_folder = 'files' 22 | self.file_filter = FileFilter() 23 | 24 | def to_config(self): 25 | return { 26 | 'endpoint': self.endpoint.to_config(), 27 | 'course_filter': self.course_filter.to_config(), 28 | 'organize_mode': self.organize_mode.to_config(), 29 | 'download_folder': self.download_folder, 30 | 'file_filter': self.file_filter.to_config() 31 | } 32 | 33 | def try_from_config(self, func): 34 | try: 35 | return func(), None 36 | except KeyError as e: 37 | return None, e 38 | 39 | def from_config(self, config): 40 | final_err = None 41 | self.download_folder, err = self.try_from_config( 42 | lambda: config.get( 43 | 'download_folder', self.download_folder)) 44 | final_err = final_err or err 45 | _, err = self.try_from_config( 46 | lambda: self.endpoint.from_config( 47 | config['endpoint']) 48 | ) 49 | final_err = final_err or err 50 | _, err = self.try_from_config( 51 | lambda: self.organize_mode.from_config(config['organize_mode'])) 52 | final_err = final_err or err 53 | _, err = self.try_from_config( 54 | lambda: self.course_filter.from_config(config['course_filter'])) 55 | final_err = final_err or err 56 | _, err = self.try_from_config( 57 | lambda: self.file_filter.from_config(config['file_filter'])) 58 | final_err = final_err or err 59 | if final_err: 60 | raise final_err 61 | 62 | def interact(self): 63 | while True: 64 | self.endpoint.interact() 65 | canvas = self.endpoint.login() 66 | try: 67 | print( 68 | f'You are logged in as {colored(canvas.get_current_user(), "cyan")}') 69 | except InvalidAccessToken: 70 | print(f'Failed to login') 71 | continue 72 | break 73 | 74 | courses, not_enrolled = filter_available_courses(canvas.get_courses()) 75 | print( 76 | f'There are {len(courses)} currently enrolled courses and {len(not_enrolled)} courses not available.') 77 | self.course_filter.interact(courses) 78 | self.organize_mode.interact() 79 | self.file_filter.interact() 80 | print("Other settings won't be covered in this wizard. Please look into `config.toml` if you want to modify.") 81 | -------------------------------------------------------------------------------- /canvas_grab/snapshot/canvas_file_snapshot.py: -------------------------------------------------------------------------------- 1 | from termcolor import colored 2 | 3 | from .snapshot import Snapshot 4 | from .snapshot_file import from_canvas_file 5 | from .snapshot_link import SnapshotLink 6 | from ..request_batcher import RequestBatcher 7 | from canvasapi.exceptions import ResourceDoesNotExist 8 | from ..utils import normalize_path, file_regex 9 | 10 | class CanvasFileSnapshot(Snapshot): 11 | """Takes a snapshot of files on Canvas, organized by file tab. 12 | 13 | ``CanvasFileSnapshot`` generates a snapshot of files on Canvas. In this snapshot mode, 14 | all files under "File" tab will be scanned as-is. Besides, it will add pages into 15 | the snapshot at `pages/xxx` path, if `with_link` option is enabled. 16 | """ 17 | 18 | def __init__(self, course, with_link=False): 19 | """Create a file-based Canvas snapshot-taker 20 | 21 | Args: 22 | course (canvasapi.course.Course): The course object 23 | with_link (bool, optional): If true, pages will be included in snapshot. Defaults to False. 24 | """ 25 | self.course = course 26 | self.with_link = with_link 27 | self.snapshot = {} 28 | 29 | def add_to_snapshot(self, key, value): 30 | """Add a key-value pair into snapshot. If duplicated, this function will report error and ignore the pair. 31 | 32 | Args: 33 | key (str): key or path of the object 34 | value (any): content of the object 35 | """ 36 | if key in self.snapshot: 37 | print(colored( 38 | f' Duplicated file found: {key}, please download it using web browser.', 'yellow')) 39 | return 40 | self.snapshot[key] = value 41 | 42 | def take_snapshot(self): 43 | """Take a snapshot 44 | 45 | Raises: 46 | ResourceDoesNotExist: this exception will be raised if file tab is not available 47 | 48 | Returns: 49 | dict: snapshot of Canvas in `SnapshotFile` or `SnapshotLink` type. 50 | """ 51 | for _ in self.yield_take_snapshot(): 52 | pass 53 | return self.get_snapshot() 54 | 55 | def yield_take_snapshot(self): 56 | course = self.course 57 | request_batcher = RequestBatcher(course) 58 | 59 | yield (0, '请稍候', '正在获取文件列表') 60 | files = request_batcher.get_files() 61 | if files is None: 62 | raise ResourceDoesNotExist("File tab is not supported.") 63 | 64 | folders = request_batcher.get_folders() 65 | 66 | for _, file in files.items(): 67 | folder = normalize_path(folders[file.folder_id].full_name) + "/" 68 | if folder.startswith("course files/"): 69 | folder = folder[len("course files/"):] 70 | snapshot_file = from_canvas_file(file) 71 | filename = f'{folder}{normalize_path(snapshot_file.name, file_regex)}' 72 | self.add_to_snapshot(filename, snapshot_file) 73 | 74 | print(f' {len(files)} files in total') 75 | yield (0.1, None, f'共 {len(files)} 个文件') 76 | 77 | if self.with_link: 78 | yield (None, '正在解析链接', None) 79 | pages = request_batcher.get_pages() or [] 80 | for page in pages: 81 | key = f'pages/{normalize_path(page.title, file_regex)}.html' 82 | value = SnapshotLink( 83 | page.title, page.html_url, "Page") 84 | self.add_to_snapshot(key, value) 85 | print(f' {len(pages)} pages in total') 86 | yield (0.2, '请稍候', f'共 {len(pages)} 个链接') 87 | 88 | def get_snapshot(self): 89 | """Get the previously-taken snapshot 90 | 91 | Returns: 92 | dict: snapshot of Canvas 93 | """ 94 | return self.snapshot 95 | -------------------------------------------------------------------------------- /canvas_grab/get_options.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from .version import VERSION 3 | from termcolor import colored 4 | from pathlib import Path 5 | from .config import Config 6 | import toml 7 | 8 | 9 | def greeting(): 10 | # First, welcome our users 11 | print("Thank you for using canvas_grab!") 12 | print( 13 | f'You are using version {VERSION}. If you have any questions, please file an issue at {colored("https://github.com/skyzh/canvas_grab/issues", "blue")}') 14 | print( 15 | f'You may review {colored("README.md", "green")} and {colored("LICENSE", "green")} shipped with this release') 16 | print( 17 | f'You may run this code with argument {colored(f"-h","cyan")} for command line usage') 18 | print('--------------------') 19 | 20 | 21 | def get_options(): 22 | # Argument Parser initiation 23 | 24 | parser = argparse.ArgumentParser( 25 | description='Grab all files on Canvas LMS to local directory.', 26 | epilog="Configuration file variables specified with program arguments will override the " 27 | "original settings at runtime, but will not be written to the original configuration file. " 28 | "If you specify a configuration file with the --config-file argument when you configure it, " 29 | "it will be overwritten with the new content.") 30 | 31 | # Interactive 32 | interactive_group = parser.add_mutually_exclusive_group() 33 | interactive_group.add_argument("-i", "--interactive", dest="interactive", action="store_true", 34 | default=True, 35 | help="Set the program to run in interactive mode (default action)") 36 | interactive_group.add_argument("-I", "--non-interactive", "--no-input", dest="interactive", 37 | action="store_false", default=True, 38 | help="Set the program to run in non-interactive mode. This can be " 39 | "used to exit immediately in case of profile corruption without " 40 | "getting stuck with the input.") 41 | 42 | # Reconfiguration 43 | parser.add_argument("-r", "--reconfigure", "--configure", dest="reconfigure", 44 | help="Reconfigure the tool.", action="store_true") 45 | 46 | # Location Specification 47 | parser.add_argument("-o", "--download-folder", "--output", 48 | dest="download", help="Specify alternative download folder.") 49 | parser.add_argument("-c", "--config-file", dest="config_file", default="config.toml", 50 | help="Specify alternative configuration file.") 51 | 52 | # Generic Options 53 | # TODO quiet mode 54 | # parser.add_argument("-q", "--quiet", dest="quiet", help="Start the program in quiet mode. " 55 | # "Only errors will be printed.", action="store_true") 56 | parser.add_argument("--version", action="version", 57 | version=VERSION) 58 | parser.add_argument("-k", "--keep-version", "--no-update", dest="noupdate", action="store_true", 59 | default=False, help="Skip update checking. This will be helpful without " 60 | "a stable network connection and prevent reconfiguration.") 61 | 62 | args = parser.parse_args() 63 | 64 | # TODO quiet mode 65 | greeting() 66 | 67 | print(f'Using config {args.config_file}') 68 | config_file = Path(args.config_file) 69 | config = Config() 70 | config_fail = False 71 | if config_file.exists(): 72 | try: 73 | config.from_config(toml.loads( 74 | config_file.read_text(encoding='utf8'))) 75 | except KeyError as e: 76 | print( 77 | f'It seems that you have upgraded canvas_grab. Please reconfigure. ({colored(e, "red")} not found)') 78 | config_fail = True 79 | if config_fail or args.reconfigure or not config_file.exists(): 80 | if not args.interactive: 81 | print( 82 | "configuration file corrupted or not exist, and non interactive flag is set. Quit immediately.") 83 | exit(-1) 84 | try: 85 | config.interact() 86 | except KeyboardInterrupt: 87 | print("User canceled the configuration process") 88 | return 89 | config_file.write_text( 90 | toml.dumps(config.to_config()), encoding='utf8') 91 | if args.download: 92 | config.download_folder = args.download 93 | 94 | return args.interactive, args.noupdate, config 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # canvas-grab 2 | 3 | **Looking for Maintainers** 4 | 5 | *As I no longer have access to Canvas systems, this project cannot be actively maintained by me. If you are interested in maintaining this project, please email me.* 6 | 7 | Grab all files on Canvas LMS to local directory. 8 | 9 | *Less is More.* In canvas_grab v2, we focus on stability and ease of use. 10 | Now you don't have to tweak dozens of configurations. We have a very 11 | simple setup wizard to help you get started! 12 | 13 | For legacy version, refer to [legacy](https://github.com/skyzh/canvas_grab/tree/legacy) branch. 14 | 15 | ## Getting Started 16 | 17 | 1. Install Python 18 | 2. Download canvas_grab source code. There are typically three ways of doing this. 19 | * Go to [Release Page](https://github.com/skyzh/canvas_grab/releases) and download `{version}.zip`. 20 | * Or `git clone https://github.com/skyzh/canvas_grab`. 21 | * Use SJTU GitLab, see [Release Page](https://git.sjtu.edu.cn/iskyzh/canvas_grab/-/tags), or 22 | visit https://git.sjtu.edu.cn/iskyzh/canvas_grab 23 | 3. Run `./canvas_grab.sh` (Linux, macOS) or `.\canvas_grab.ps1` (Windows) in Terminal. 24 | Please refer to `Build and Run from Source` for more information. 25 | 4. Get your API key at Canvas profile and you're ready to go! 26 | 5. Please don't modify any file inside download folder (e.g take notes, add supplementary items). They will be overwritten upon each run. 27 | 28 | You may interrupt the downloading process at any time. The program will automatically resume from where it stopped. 29 | 30 | To upgrade, just replace `canvas_grab` with a more recent version. 31 | 32 | If you have any questions, feel free to file an issue [here](https://github.com/skyzh/canvas_grab/issues). 33 | 34 | ## Build and Run from Source 35 | 36 | First of all, please install Python 3.8+, and download source code. 37 | 38 | We have prepared a simple script to automatically install dependencies and run canvas_grab. 39 | 40 | For macOS or Linux users, open a Terminal and run: 41 | 42 | ```bash 43 | ./canvas_grab.sh 44 | ``` 45 | 46 | For Windows users: 47 | 48 | 1. Right-click Windows icon on taskbar, and select "Run Powershell (Administrator)". 49 | 2. Run `Set-ExecutionPolicy Unrestricted` in Powershell. 50 | 3. If some courses in Canvas LMS have very long module names that exceed Windows limits (which will causes "No such file" error 51 | when downloading), run the following command to enable long path support. 52 | ``` 53 | Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled -Type DWord -Value 1 54 | ``` 55 | 4. Open `canvas_grab` source file in file browser, Shift + Right-click on blank area, and select `Run Powershell here`. 56 | 5. Now you can start canvas_grab with a simple command: 57 | ```powershell 58 | .\canvas_grab.ps1 59 | ``` 60 | 61 | ## Configure 62 | 63 | The setup wizard will automatically create a configuration for you. 64 | You can change `config.toml` to fit your needs. If you need to 65 | re-configure, run `./configure.sh` or `./configure.ps1`. 66 | 67 | ## Common Issues 68 | 69 | * **Acquire API token** Access Token can be obtained at "Account - Settings - New Access Token". 70 | * **SJTU users** 请在[此页面](https://oc.sjtu.edu.cn/profile/settings#access_tokens_holder)内通过“创建新访问许可证”按钮生成访问令牌。 71 | * **An error occurred** You'll see "An error occurred when processing this course" if there's no file in a course. 72 | * **File not available** This file might have been included in an unpublished unit. canvas_grab cannot bypass restrictions. 73 | * **No module named 'canvasapi'** You haven't installed the dependencies. Follow steps in "build and run from source" or download prebuilt binaries. 74 | * **Error when checking update** It's normal if you don't have a stable connection to GitHub. You may regularly check updates by visiting this repo. 75 | * **Reserved escape sequence used** please use "/" as the path seperator instead of "\\". 76 | * **Duplicated files detected** There're two files of same name in same folder. You should download it from Canvas yourself. 77 | 78 | ## Screenshot 79 | 80 | ![image](https://user-images.githubusercontent.com/4198311/108496621-4673bf00-72e5-11eb-8978-8b8bdd4efea5.png) 81 | 82 | ![gui](https://user-images.githubusercontent.com/4198311/113378330-4e755300-93a9-11eb-81a9-c494a8cc7488.png) 83 | 84 | ## Contributors 85 | 86 | See [Contributors](https://github.com/skyzh/canvas_grab/graphs/contributors) list. 87 | [@skyzh](https://github.com/skyzh), [@danyang685](https://github.com/danyang685) are two core maintainers. 88 | 89 | ## License 90 | 91 | MIT 92 | 93 | Which means that we do not shoulder any responsibilities for, included but not limited to: 94 | 95 | 1. API key leaking 96 | 2. Users upload copyright material from website to the Internet 97 | -------------------------------------------------------------------------------- /canvas_grab/transfer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from pathlib import Path 4 | from retrying import retry 5 | from termcolor import colored 6 | 7 | from .download_file import download_file as df 8 | from .utils import apply_datetime_attr, truncate_name 9 | from .snapshot import SnapshotLink, SnapshotFile 10 | 11 | TIMEOUT = 3 12 | ATTEMPT = 3 13 | 14 | 15 | def need_retrying(exception): 16 | return not isinstance(exception, KeyboardInterrupt) 17 | 18 | 19 | @retry(retry_on_exception=need_retrying, stop_max_attempt_number=ATTEMPT, wait_fixed=1000) 20 | def download_file(url, desc, filename, file_size, verbose=False): 21 | try: 22 | sys.stderr.flush() 23 | yield from df(url, desc, filename, file_size, verbose, req_timeout=TIMEOUT) 24 | sys.stderr.flush() 25 | except KeyboardInterrupt: 26 | sys.stderr.flush() 27 | raise 28 | except Exception as e: 29 | sys.stderr.flush() 30 | print(' ' + colored(f'Retrying ({e})...', 'red')) 31 | raise 32 | 33 | 34 | class Transfer(object): 35 | """Transfer files with Transfer class 36 | """ 37 | 38 | def create_parent_folder(self, path): 39 | Path(path).parent.mkdir(parents=True, exist_ok=True) 40 | 41 | def transfer(self, base_path, archive_base_path, plans): 42 | for _ in self.yield_transfer(base_path, archive_base_path, plans): 43 | pass 44 | 45 | def sub_transfer_progress(self, of, total, download_progress): 46 | return 0.2 + (float(of) + download_progress) / total * 0.8 47 | 48 | def yield_transfer(self, base_path, archive_base_path, plans): 49 | yield (None, '传输文件中...', None) 50 | for idx, (op, key, plan) in enumerate(plans): 51 | path = f'{base_path}/{key}' 52 | archive_path = f'{archive_base_path}/{path}' 53 | 54 | if op == 'add' or op == 'update': 55 | self.create_parent_folder(path) 56 | file_obj = Path(path) 57 | if file_obj.exists(): 58 | self.create_parent_folder(archive_path) 59 | file_obj.replace(archive_path) 60 | if plan.url == '': 61 | print(f' {colored("? (not available)", "yellow")} {key}') 62 | continue 63 | if isinstance(plan, SnapshotFile): 64 | download_file_task = download_file( 65 | plan.url, f'({idx+1}/{len(plans)}) ' + truncate_name(plan.name), path, plan.size) 66 | for progress in download_file_task: 67 | yield (self.sub_transfer_progress(idx, len(plans), progress), None, None) 68 | apply_datetime_attr( 69 | path, plan.created_at, plan.modified_at) 70 | elif isinstance(plan, SnapshotLink): 71 | Path(path).write_text(plan.content(), encoding='utf-8') 72 | else: 73 | print(colored('Unsupported snapshot type', 'red')) 74 | 75 | if op == 'delete': 76 | file_obj = Path(path) 77 | if file_obj.exists(): 78 | self.create_parent_folder(archive_path) 79 | file_obj.rename(archive_path) 80 | 81 | if op == 'add': 82 | print(f' {colored("+", "green")} {key}') 83 | yield (None, None, f'下载 {key}') 84 | if op == 'update': 85 | print(f' {colored("=", "green")} {key}') 86 | yield (None, None, f'更新 {key}') 87 | if op == 'delete': 88 | print(f' {colored("-", "yellow")} {key}') 89 | yield (None, None, f'删除 {key}') 90 | if op == 'ignore': 91 | print(f' {colored("? (ignored)", "yellow")} {key}') 92 | yield (None, None, f'忽略 {key}') 93 | if op == 'try-remove': 94 | print(f' {colored("? (not on remote)", "yellow")} {key}') 95 | yield (None, None, f'忽略 {key}') 96 | 97 | self.clean_tree(base_path) 98 | 99 | def clean_tree(self, path) -> bool: 100 | """Remove empty folder recursively. 101 | Returns True if folder is deleted. 102 | """ 103 | path = Path(path) 104 | if not path.is_dir(): 105 | return True 106 | children = cleaned_children = list(path.glob('*')) 107 | for child_idx in reversed(range(len(children))): 108 | if children[child_idx].is_dir() and self.clean_tree(children[child_idx]): 109 | del cleaned_children[child_idx] 110 | if not cleaned_children: 111 | path.rmdir() 112 | return True 113 | return False 114 | -------------------------------------------------------------------------------- /canvas_grab_gui/sync_model.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import * 2 | from time import sleep 3 | 4 | 5 | class SyncModel(QAbstractListModel): 6 | """Model of listing information on Canvas Grab GUI 7 | """ 8 | NameRole = Qt.UserRole + 1 9 | StatusRole = Qt.UserRole + 2 10 | StatusTextRole = Qt.UserRole + 3 11 | ProgressTextRole = Qt.UserRole + 4 12 | ProgressRole = Qt.UserRole + 5 13 | IconNameRole = Qt.UserRole + 6 14 | 15 | def __init__(self, parent=None): 16 | super().__init__(parent) 17 | self.items = [ 18 | {'name': '准备中', 'status': 'inProgress', 19 | 'status_text': "请稍候", 20 | 'progress_text': "正在登录到 Canvas LMS", 21 | 'progress': 0.0}] 22 | self.on_update_login_user.connect(self.update_login_user) 23 | self.on_done_fetching_courses.connect(self.done_fetching_courses) 24 | self.on_new_course_in_progress.connect(self.new_course_in_progress) 25 | self.on_snapshot_in_progress.connect(self.snapshot_in_progress) 26 | self.on_download_in_progress.connect(self.download_in_progress) 27 | self.on_finish_course.connect(self.finish_course) 28 | 29 | def data(self, index, role=Qt.DisplayRole): 30 | row = index.row() 31 | if role == SyncModel.NameRole: 32 | return self.items[row]['name'] 33 | if role == SyncModel.StatusRole: 34 | return self.items[row]['status'] 35 | if role == SyncModel.StatusTextRole: 36 | return self.items[row]['status_text'] 37 | if role == SyncModel.ProgressTextRole: 38 | return self.items[row]['progress_text'] 39 | if role == SyncModel.ProgressRole: 40 | return self.items[row]['progress'] 41 | if role == SyncModel.IconNameRole: 42 | return self.items[row]['icon_name'] 43 | 44 | def rowCount(self, parent=QModelIndex()): 45 | return len(self.items) 46 | 47 | def roleNames(self): 48 | return { 49 | SyncModel.NameRole: b'name', 50 | SyncModel.StatusRole: b'status', 51 | SyncModel.StatusTextRole: b'statusText', 52 | SyncModel.ProgressTextRole: b'progressText', 53 | SyncModel.ProgressRole: b'progress', 54 | SyncModel.IconNameRole: b'iconName' 55 | } 56 | 57 | on_update_login_user = Signal(str) 58 | 59 | @Slot(str) 60 | def update_login_user(self, user): 61 | self.beginResetModel() 62 | self.items[0]['progress_text'] = f'以 {user} 的身份登录' 63 | self.items[0]['status_text'] = '正在获取课程列表' 64 | self.items[0]['progress'] = 0.5 65 | self.endResetModel() 66 | 67 | on_done_fetching_courses = Signal(str) 68 | 69 | @Slot(str) 70 | def done_fetching_courses(self, text): 71 | self.beginResetModel() 72 | self.items[0] = { 73 | 'name': '已登录到 Canvas LMS', 'status': 'done', 74 | 'progress_text': text, 75 | 'icon_name': 'box-arrow-in-right' 76 | } 77 | self.endResetModel() 78 | 79 | on_new_course_in_progress = Signal(str) 80 | 81 | @Slot(str) 82 | def new_course_in_progress(self, text): 83 | self.beginResetModel() 84 | self.items = [self.items[0], { 85 | 'name': text, 'status': 'inProgress', 86 | 'status_text': '请稍候', 87 | 'progress_text': '正在扫描单元/文件列表', 88 | 'progress': 0.0 89 | }] + self.items[1:] 90 | self.endResetModel() 91 | 92 | on_snapshot_in_progress = Signal(float, str, str) 93 | 94 | @Slot(float, str, str) 95 | def snapshot_in_progress(self, progress, status_text, progress_text): 96 | self.beginResetModel() 97 | if status_text is not None: 98 | self.items[1]['status_text'] = status_text 99 | if progress_text is not None: 100 | self.items[1]['progress_text'] = progress_text 101 | if progress is not None: 102 | self.items[1]['progress'] = progress 103 | self.endResetModel() 104 | 105 | on_download_in_progress = Signal(float, str, str) 106 | 107 | @Slot(float, str, str) 108 | def download_in_progress(self, progress, status_text, progress_text): 109 | self.beginResetModel() 110 | if status_text != "": 111 | self.items[1]['status_text'] = status_text 112 | if progress_text != "": 113 | self.items[1]['progress_text'] = progress_text 114 | if progress > 0: 115 | self.items[1]['progress'] = progress 116 | self.endResetModel() 117 | 118 | on_finish_course = Signal(str, str) 119 | 120 | @Slot(str, str) 121 | def finish_course(self, title, text): 122 | self.beginResetModel() 123 | self.items = [self.items[0], { 124 | 'name': title, 'status': 'done', 125 | 'progress_text': text, 126 | 'icon_name': 'cloud-check' 127 | }] + self.items[2:] 128 | self.endResetModel() 129 | -------------------------------------------------------------------------------- /canvas_grab/snapshot/canvas_module_snapshot.py: -------------------------------------------------------------------------------- 1 | import re 2 | from termcolor import colored 3 | 4 | from .snapshot import Snapshot 5 | from .snapshot_file import from_canvas_file 6 | from .snapshot_link import SnapshotLink 7 | from ..utils import normalize_path, file_regex 8 | from ..request_batcher import RequestBatcher 9 | 10 | 11 | class CanvasModuleSnapshot(Snapshot): 12 | """Take a snapshot of files on Canvas, organized by module tab. 13 | 14 | ``CanvasModuleSnapshot`` generates a snapshot of Canvas by scanning the module tab. 15 | This is useful if 1) "File" tab is not available 2) Users want to organize files 16 | by module. If "File" tab is available, the snapshot-taker will first acquire all 17 | files in "File" tab, which batches the requests and greatly improves performance. 18 | If `with_link` is enabled, pages and external links will be included in snapshot. 19 | """ 20 | 21 | def __init__(self, course, with_link=False): 22 | """Create a module-based Canvas snapshot-taker 23 | 24 | Args: 25 | course (canvasapi.course.Course): The course object 26 | with_link (bool, optional): If true, pages will be included in snapshot. Defaults to False. 27 | """ 28 | self.course = course 29 | self.snapshot = {} 30 | self.with_link = with_link 31 | 32 | def add_to_snapshot(self, key, value): 33 | """Add a key-value pair into snapshot. If duplicated, this function will report error and ignore the pair. 34 | 35 | Args: 36 | key (str): key or path of the object 37 | value (any): content of the object 38 | """ 39 | if key in self.snapshot: 40 | print(colored( 41 | f' Duplicated file found: {key}, please download it using web browser.', 'yellow')) 42 | return 43 | self.snapshot[key] = value 44 | 45 | def take_snapshot(self): 46 | """Take a snapshot 47 | 48 | Returns: 49 | dict: snapshot of Canvas in `SnapshotFile` or `SnapshotLink` type. 50 | """ 51 | for _ in self.yield_take_snapshot(): 52 | pass 53 | return self.get_snapshot() 54 | 55 | def yield_take_snapshot(self): 56 | course = self.course 57 | request_batcher = RequestBatcher(course) 58 | accessed_files = [] 59 | yield (0, '请稍候', '正在获取模块列表') 60 | 61 | modules = (request_batcher.get_modules() or {}).items() 62 | download_idx = 0 63 | for _, module in modules: 64 | # replace invalid characters in name 65 | name = re.sub(file_regex, "_", module.name) 66 | # consolidate spaces 67 | name = " ".join(name.split()) 68 | 69 | # get module index 70 | idx = str(module.position) 71 | 72 | # folder format 73 | module_name = f'{idx} {name}' 74 | 75 | module_item_count = module.items_count 76 | print( 77 | f' Module {colored(module_name, "cyan")} ({module_item_count} items)') 78 | 79 | yield (download_idx / len(modules) * 0.2, '正在获取模块列表', f'{module_name} (包含 {module_item_count} 个对象)') 80 | download_idx += 1 81 | 82 | for item in module.get_module_items(): 83 | if item.type == 'File': 84 | file_id = item.content_id 85 | snapshot_file = from_canvas_file( 86 | request_batcher.get_file(file_id)) 87 | accessed_files.append(file_id) 88 | filename = f'{module_name}/{normalize_path(snapshot_file.name, file_regex)}' 89 | self.add_to_snapshot(filename, snapshot_file) 90 | if self.with_link: 91 | if item.type == 'ExternalUrl' or item.type == 'Page': 92 | key = f'{module_name}/{normalize_path(item.title, file_regex)}.html' 93 | value = SnapshotLink( 94 | item.title, item.html_url, module_name) 95 | self.add_to_snapshot(key, value) 96 | 97 | files = request_batcher.get_files() 98 | if files: 99 | unmoduled_files = 0 100 | for file_id, file in files.items(): 101 | if file_id not in accessed_files: 102 | snapshot_file = from_canvas_file(file) 103 | filename = f'unmoduled/{normalize_path(snapshot_file.name, file_regex)}' 104 | self.add_to_snapshot(filename, snapshot_file) 105 | unmoduled_files += 1 106 | print( 107 | f' {colored("Unmoduled files", "cyan")} ({unmoduled_files} items)') 108 | yield (0.2, '正在获取模块列表', f'还有 {unmoduled_files} 个不在模块中的文件') 109 | 110 | def get_snapshot(self): 111 | """Get the previously-taken snapshot 112 | 113 | Returns: 114 | dict: snapshot of Canvas 115 | """ 116 | return self.snapshot 117 | -------------------------------------------------------------------------------- /canvas_grab_gui/main.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtGui import QGuiApplication 2 | from PySide6.QtCore import QStringListModel, Qt, QUrl 3 | from PySide6.QtQuick import QQuickView 4 | from PySide6.QtQml import QQmlApplicationEngine 5 | import sys 6 | import os 7 | import canvas_grab 8 | import threading 9 | from .sync_model import SyncModel 10 | from colorama import init 11 | from termcolor import colored 12 | from time import sleep 13 | from canvasapi.exceptions import ResourceDoesNotExist 14 | 15 | 16 | class Main: 17 | def __init__(self): 18 | self._model = SyncModel() 19 | 20 | def _canvas_grab_run(self): 21 | config = self._config 22 | canvas = config.endpoint.login() 23 | user = canvas.get_current_user() 24 | self._model.on_update_login_user.emit(str(user)) 25 | courses = list(canvas.get_courses()) 26 | available_courses, not_available = canvas_grab.utils.filter_available_courses( 27 | courses) 28 | filtered_courses = config.course_filter.get_filter().filter_course( 29 | available_courses) 30 | 31 | total_course_count = len(courses) 32 | not_available_count = len(not_available) 33 | filtered_count = len(available_courses) - len(filtered_courses) 34 | self._model.on_done_fetching_courses.emit( 35 | f'您已经以 {user} 身份登录。共有 {total_course_count} 门课程需同步,其中 {not_available_count} 门无法访问,{filtered_count} 门已被过滤。') 36 | 37 | course_name_parser = canvas_grab.course_parser.CourseParser() 38 | for idx, course in enumerate(filtered_courses): 39 | course_name = course.name 40 | self._model.on_new_course_in_progress.emit( 41 | f'({idx+1}/{len(filtered_courses)}) {course_name} (ID: {course.id})') 42 | # take on-disk snapshot 43 | parsed_name = course_name_parser.get_parsed_name(course) 44 | print(f' Download to {colored(parsed_name, "cyan")}') 45 | on_disk_path = f'{config.download_folder}/{parsed_name}' 46 | on_disk_snapshot = canvas_grab.snapshot.OnDiskSnapshot( 47 | on_disk_path).take_snapshot() 48 | 49 | # take canvas snapshot 50 | mode, canvas_snapshots = config.organize_mode.get_snapshots(course) 51 | canvas_snapshot = {} 52 | for canvas_snapshot_obj in canvas_snapshots: 53 | try: 54 | for progress_item in canvas_snapshot_obj.yield_take_snapshot(): 55 | (progress, status_text, progress_text) = progress_item 56 | self._model.on_snapshot_in_progress.emit( 57 | progress, status_text, progress_text) 58 | canvas_snapshot = canvas_snapshot_obj.get_snapshot() 59 | except ResourceDoesNotExist: 60 | print( 61 | colored(f'{mode} not supported, falling back to alternative mode', 'yellow')) 62 | continue 63 | break 64 | # generate transfer plan 65 | planner = canvas_grab.planner.Planner( 66 | config.organize_mode.delete_file) 67 | plans = planner.plan( 68 | canvas_snapshot, on_disk_snapshot, config.file_filter) 69 | print(colored( 70 | f' Updating {len(plans)} objects ')) 71 | 72 | # start download 73 | transfer = canvas_grab.transfer.Transfer() 74 | transfer_task = transfer.yield_transfer( 75 | on_disk_path, f'{config.download_folder}/_canvas_grab_archive', plans) 76 | 77 | for progress_item in transfer_task: 78 | (progress, status_text, progress_text) = progress_item 79 | self._model.on_download_in_progress.emit( 80 | progress, status_text, progress_text) 81 | 82 | self._model.on_finish_course.emit( 83 | f'{course_name} (ID: {course.id})', 84 | f'更新了 {len(plans)} 个文件。(远程 {len(canvas_snapshot)} -> 本地 {len(on_disk_snapshot)})') 85 | 86 | if not self._noupdate: 87 | canvas_grab.version.check_latest_version() 88 | 89 | def _exit_handler(self): 90 | os._exit(0) 91 | 92 | def main(self): 93 | init() 94 | # Welcome users, and load configurations. 95 | try: 96 | _, self._noupdate, self._config = canvas_grab.get_options.get_options() 97 | except TypeError: 98 | # User canceled the configuration process 99 | return 100 | 101 | app = QGuiApplication(sys.argv) 102 | app.setQuitOnLastWindowClosed(True) 103 | app.aboutToQuit.connect(self._exit_handler) 104 | engine = QQmlApplicationEngine() 105 | engine.rootContext().setContextProperty('py_sync_model', self._model) 106 | engine.load(os.path.join(os.path.dirname(__file__), "ui/main.qml")) 107 | 108 | if not engine.rootObjects(): 109 | sys.exit(-1) 110 | 111 | thread = threading.Thread(target=self._canvas_grab_run) 112 | thread.start() 113 | 114 | sys.exit(app.exec_()) 115 | 116 | 117 | if __name__ == "__main__": 118 | Main().main() 119 | -------------------------------------------------------------------------------- /canvas_grab_gui/main.pyproject.user: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | EnvironmentId 7 | {48ff3b3d-4c8d-4aa5-8dd1-9bdd4bf26c24} 8 | 9 | 10 | ProjectExplorer.Project.ActiveTarget 11 | 0 12 | 13 | 14 | ProjectExplorer.Project.EditorSettings 15 | 16 | true 17 | false 18 | true 19 | 20 | Cpp 21 | 22 | CppGlobal 23 | 24 | 25 | 26 | QmlJS 27 | 28 | QmlJSGlobal 29 | 30 | 31 | 2 32 | UTF-8 33 | false 34 | 4 35 | false 36 | 80 37 | true 38 | true 39 | 1 40 | true 41 | false 42 | 0 43 | true 44 | true 45 | 0 46 | 8 47 | true 48 | 1 49 | true 50 | true 51 | true 52 | *.md, *.MD, Makefile 53 | false 54 | true 55 | 56 | 57 | 58 | ProjectExplorer.Project.PluginSettings 59 | 60 | 61 | true 62 | true 63 | true 64 | true 65 | true 66 | 67 | 68 | 0 69 | true 70 | 71 | true 72 | Builtin.Questionable 73 | 74 | true 75 | true 76 | Builtin.DefaultTidyAndClazy 77 | 4 78 | 79 | 80 | 81 | true 82 | 83 | 84 | 85 | 86 | ProjectExplorer.Project.Target.0 87 | 88 | Desktop 89 | Desktop Qt 6.0.3 clang 64bit 90 | Desktop Qt 6.0.3 clang 64bit 91 | qt.qt6.603.clang_64_kit 92 | -1 93 | 0 94 | 0 95 | 0 96 | 97 | 98 | 0 99 | Deploy 100 | Deploy 101 | ProjectExplorer.BuildSteps.Deploy 102 | 103 | 1 104 | 105 | false 106 | ProjectExplorer.DefaultDeployConfiguration 107 | 108 | 1 109 | 110 | dwarf 111 | 112 | cpu-cycles 113 | 114 | 115 | 250 116 | 117 | -e 118 | cpu-cycles 119 | --call-graph 120 | dwarf,4096 121 | -F 122 | 250 123 | 124 | -F 125 | true 126 | 4096 127 | false 128 | false 129 | 1000 130 | 131 | true 132 | 133 | false 134 | false 135 | false 136 | false 137 | true 138 | 0.01 139 | 10 140 | true 141 | kcachegrind 142 | 1 143 | 25 144 | 145 | 1 146 | true 147 | false 148 | true 149 | valgrind 150 | 151 | 0 152 | 1 153 | 2 154 | 3 155 | 4 156 | 5 157 | 6 158 | 7 159 | 8 160 | 9 161 | 10 162 | 11 163 | 12 164 | 13 165 | 14 166 | 167 | 168 | 2 169 | 170 | main2 171 | PythonEditor.RunConfiguration./Users/skyzh/Work/canvas_grab/canvas_grab_gui/main.py 172 | /Users/skyzh/Work/canvas_grab/canvas_grab_gui/main.py 173 | {57b28e85-c3b1-41aa-a4dc-3b99640c1c78} 174 | /Users/skyzh/Work/canvas_grab/canvas_grab_gui/main.py 175 | false 176 | true 177 | false 178 | true 179 | /Users/skyzh/Work/canvas_grab/canvas_grab_gui 180 | 181 | 1 182 | 183 | 184 | 185 | ProjectExplorer.Project.TargetCount 186 | 1 187 | 188 | 189 | ProjectExplorer.Project.Updater.FileVersion 190 | 22 191 | 192 | 193 | Version 194 | 22 195 | 196 | 197 | --------------------------------------------------------------------------------