├── 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 |
--------------------------------------------------------------------------------
/canvas_grab_gui/ui/icons/info-circle-fill.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 
81 |
82 | 
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 |
--------------------------------------------------------------------------------