ctxsync 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctxsync/__init__.py +0 -0
- ctxsync/chat_sync.py +186 -0
- ctxsync/cli/__init__.py +3 -0
- ctxsync/cli/auth.py +77 -0
- ctxsync/cli/category.py +71 -0
- ctxsync/cli/chat.py +357 -0
- ctxsync/cli/config.py +72 -0
- ctxsync/cli/file.py +29 -0
- ctxsync/cli/main.py +257 -0
- ctxsync/cli/organization.py +98 -0
- ctxsync/cli/project.py +422 -0
- ctxsync/cli/session.py +626 -0
- ctxsync/cli/submodule.py +148 -0
- ctxsync/cli/sync.py +79 -0
- ctxsync/compression.py +302 -0
- ctxsync/configmanager/__init__.py +5 -0
- ctxsync/configmanager/base_config_manager.py +255 -0
- ctxsync/configmanager/file_config_manager.py +362 -0
- ctxsync/configmanager/inmemory_config_manager.py +134 -0
- ctxsync/exceptions.py +22 -0
- ctxsync/provider_factory.py +38 -0
- ctxsync/providers/__init__.py +0 -0
- ctxsync/providers/base_claude_ai.py +537 -0
- ctxsync/providers/base_provider.py +109 -0
- ctxsync/providers/claude_ai.py +192 -0
- ctxsync/session_key_manager.py +129 -0
- ctxsync/syncmanager.py +328 -0
- ctxsync/utils.py +416 -0
- ctxsync-0.8.0.dist-info/METADATA +151 -0
- ctxsync-0.8.0.dist-info/RECORD +34 -0
- ctxsync-0.8.0.dist-info/WHEEL +5 -0
- ctxsync-0.8.0.dist-info/entry_points.txt +2 -0
- ctxsync-0.8.0.dist-info/licenses/LICENSE +21 -0
- ctxsync-0.8.0.dist-info/top_level.txt +1 -0
ctxsync/cli/submodule.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from ctxsync.exceptions import ProviderError
|
|
6
|
+
from ..utils import (
|
|
7
|
+
handle_errors,
|
|
8
|
+
validate_and_get_provider,
|
|
9
|
+
detect_submodules,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def submodule():
|
|
15
|
+
"""Manage submodules within the current project."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@submodule.command()
|
|
20
|
+
@click.pass_obj
|
|
21
|
+
@handle_errors
|
|
22
|
+
def ls(config):
|
|
23
|
+
"""List all detected submodules in the current project."""
|
|
24
|
+
local_path = config.get_local_path()
|
|
25
|
+
if not local_path:
|
|
26
|
+
click.echo(
|
|
27
|
+
"No local project path found. Please select an existing project or create a new one using "
|
|
28
|
+
"'ctxsync project select' or 'ctxsync project create'."
|
|
29
|
+
)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
submodule_detect_filenames = config.get("submodule_detect_filenames", [])
|
|
33
|
+
submodules = detect_submodules(local_path, submodule_detect_filenames)
|
|
34
|
+
|
|
35
|
+
if not submodules:
|
|
36
|
+
click.echo("No submodules detected in the current project.")
|
|
37
|
+
else:
|
|
38
|
+
click.echo("Detected submodules:")
|
|
39
|
+
for submodule, detected_file in submodules:
|
|
40
|
+
click.echo(f" - {submodule} [{detected_file}]")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@submodule.command()
|
|
44
|
+
@click.pass_obj
|
|
45
|
+
@handle_errors
|
|
46
|
+
def create(config):
|
|
47
|
+
"""Creates new projects for each detected submodule that doesn't already exist remotely."""
|
|
48
|
+
provider = validate_and_get_provider(config, require_project=True)
|
|
49
|
+
active_organization_id = config.get("active_organization_id")
|
|
50
|
+
active_project_id = config.get("active_project_id")
|
|
51
|
+
active_project_name = config.get("active_project_name")
|
|
52
|
+
local_path = config.get_local_path()
|
|
53
|
+
|
|
54
|
+
if not local_path:
|
|
55
|
+
click.echo(
|
|
56
|
+
"No local project path found. Please select an existing project or create a new one using "
|
|
57
|
+
"'ctxsync project select' or 'ctxsync project create'."
|
|
58
|
+
)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
submodule_detect_filenames = config.get("submodule_detect_filenames", [])
|
|
62
|
+
submodules_with_files = detect_submodules(local_path, submodule_detect_filenames)
|
|
63
|
+
|
|
64
|
+
if not submodules_with_files:
|
|
65
|
+
click.echo("No submodules detected in the current project.")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Fetch all remote projects
|
|
69
|
+
all_remote_projects = provider.get_projects(
|
|
70
|
+
active_organization_id, include_archived=False
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
click.echo(
|
|
74
|
+
f"Detected {len(submodules_with_files)} submodule(s). Checking for existing remote projects:"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Load existing local config (the project may still use a legacy .claudesync dir)
|
|
78
|
+
local_config_path = os.path.join(local_path, ".ctxsync", "config.local.json")
|
|
79
|
+
if not os.path.exists(local_config_path):
|
|
80
|
+
legacy_path = os.path.join(local_path, ".claudesync", "config.local.json")
|
|
81
|
+
if os.path.exists(legacy_path):
|
|
82
|
+
local_config_path = legacy_path
|
|
83
|
+
with open(local_config_path, "r") as f:
|
|
84
|
+
local_config = json.load(f)
|
|
85
|
+
|
|
86
|
+
# Initialize submodules list if it doesn't exist
|
|
87
|
+
if "submodules" not in local_config:
|
|
88
|
+
local_config["submodules"] = []
|
|
89
|
+
|
|
90
|
+
for i, (submodule, detected_file) in enumerate(submodules_with_files, 1):
|
|
91
|
+
submodule_name = os.path.basename(submodule)
|
|
92
|
+
new_project_name = f"{active_project_name}-SubModule-{submodule_name}"
|
|
93
|
+
|
|
94
|
+
# Check if the submodule project already exists
|
|
95
|
+
existing_project = next(
|
|
96
|
+
(p for p in all_remote_projects if p["name"] == new_project_name), None
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if existing_project:
|
|
100
|
+
click.echo(
|
|
101
|
+
f"{i}. Submodule '{submodule_name}' already exists as project "
|
|
102
|
+
f"'{new_project_name}' (ID: {existing_project['id']}). Updating local config."
|
|
103
|
+
)
|
|
104
|
+
project_id = existing_project["id"]
|
|
105
|
+
else:
|
|
106
|
+
description = f"Submodule '{submodule_name}' for project '{active_project_name}' (ID: {active_project_id})"
|
|
107
|
+
try:
|
|
108
|
+
new_project = provider.create_project(
|
|
109
|
+
active_organization_id, new_project_name, description
|
|
110
|
+
)
|
|
111
|
+
project_id = new_project["uuid"]
|
|
112
|
+
click.echo(
|
|
113
|
+
f"{i}. Created project '{new_project_name}' (ID: {project_id}) for submodule '{submodule_name}'"
|
|
114
|
+
)
|
|
115
|
+
except ProviderError as e:
|
|
116
|
+
click.echo(
|
|
117
|
+
f"Failed to create project for submodule '{submodule_name}': {str(e)}"
|
|
118
|
+
)
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Update or add submodule information in local config
|
|
122
|
+
submodule_config = {
|
|
123
|
+
"active_provider": config.get("active_provider"),
|
|
124
|
+
"active_organization_id": active_organization_id,
|
|
125
|
+
"active_project_id": project_id,
|
|
126
|
+
"active_project_name": new_project_name,
|
|
127
|
+
"relative_path": submodule,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Check if submodule already exists in config and update it, or append new entry
|
|
131
|
+
submodule_index = next(
|
|
132
|
+
(
|
|
133
|
+
index
|
|
134
|
+
for (index, d) in enumerate(local_config["submodules"])
|
|
135
|
+
if d["relative_path"] == submodule
|
|
136
|
+
),
|
|
137
|
+
None,
|
|
138
|
+
)
|
|
139
|
+
if submodule_index is not None:
|
|
140
|
+
local_config["submodules"][submodule_index] = submodule_config
|
|
141
|
+
else:
|
|
142
|
+
local_config["submodules"].append(submodule_config)
|
|
143
|
+
|
|
144
|
+
# Save updated local config
|
|
145
|
+
with open(local_config_path, "w") as f:
|
|
146
|
+
json.dump(local_config, f, indent=2)
|
|
147
|
+
|
|
148
|
+
click.echo("\nSubmodule project creation and configuration update completed.")
|
ctxsync/cli/sync.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
import click
|
|
5
|
+
from crontab import CronTab
|
|
6
|
+
|
|
7
|
+
from ..utils import handle_errors, validate_and_get_provider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.pass_obj
|
|
12
|
+
@handle_errors
|
|
13
|
+
def ls(config):
|
|
14
|
+
"""List files in the active remote project."""
|
|
15
|
+
provider = validate_and_get_provider(config, require_project=True)
|
|
16
|
+
active_organization_id = config.get("active_organization_id")
|
|
17
|
+
active_project_id = config.get("active_project_id")
|
|
18
|
+
files = provider.list_files(active_organization_id, active_project_id)
|
|
19
|
+
if not files:
|
|
20
|
+
click.echo("No files found in the active project.")
|
|
21
|
+
else:
|
|
22
|
+
click.echo(
|
|
23
|
+
f"Files in project '{config.get('active_project_name')}' (ID: {active_project_id}):"
|
|
24
|
+
)
|
|
25
|
+
for file in files:
|
|
26
|
+
click.echo(
|
|
27
|
+
f" - {file['file_name']} (ID: {file['uuid']}, Created: {file['created_at']})"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_local_path(local_path):
|
|
32
|
+
if not local_path:
|
|
33
|
+
click.echo(
|
|
34
|
+
"No local path set. Please select or create a project to set the local path."
|
|
35
|
+
)
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
if not os.path.exists(local_path):
|
|
38
|
+
click.echo(f"The configured local path does not exist: {local_path}")
|
|
39
|
+
click.echo("Please update the local path by selecting or creating a project.")
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@click.command()
|
|
44
|
+
@click.pass_obj
|
|
45
|
+
@click.option(
|
|
46
|
+
"--interval", type=int, default=5, prompt="Enter sync interval in minutes"
|
|
47
|
+
)
|
|
48
|
+
@handle_errors
|
|
49
|
+
def schedule(config, interval):
|
|
50
|
+
"""Set up automated synchronization at regular intervals."""
|
|
51
|
+
ctxsync_path = shutil.which("ctxsync")
|
|
52
|
+
if not ctxsync_path:
|
|
53
|
+
click.echo(
|
|
54
|
+
"Error: ctxsync not found in PATH. Please ensure it's installed correctly."
|
|
55
|
+
)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
if sys.platform.startswith("win"):
|
|
59
|
+
setup_windows_task(ctxsync_path, interval)
|
|
60
|
+
else:
|
|
61
|
+
setup_unix_cron(ctxsync_path, interval)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def setup_windows_task(ctxsync_path, interval):
|
|
65
|
+
click.echo("Windows Task Scheduler setup:")
|
|
66
|
+
command = f'schtasks /create /tn "ctxsync" /tr "{ctxsync_path} sync" /sc minute /mo {interval}'
|
|
67
|
+
click.echo(f"Run this command to create the task:\n{command}")
|
|
68
|
+
click.echo('\nTo remove the task, run: schtasks /delete /tn "ctxsync" /f')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def setup_unix_cron(ctxsync_path, interval):
|
|
72
|
+
cron = CronTab(user=True)
|
|
73
|
+
job = cron.new(command=f"{ctxsync_path} sync")
|
|
74
|
+
job.minute.every(interval)
|
|
75
|
+
cron.write()
|
|
76
|
+
click.echo(f"Cron job created successfully! It will run every {interval} minutes.")
|
|
77
|
+
click.echo(
|
|
78
|
+
"\nTo remove the cron job, run: crontab -e and remove the line for ctxsync"
|
|
79
|
+
)
|
ctxsync/compression.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import zlib
|
|
3
|
+
import bz2
|
|
4
|
+
import lzma
|
|
5
|
+
import base64
|
|
6
|
+
import brotli
|
|
7
|
+
from collections import Counter
|
|
8
|
+
import os
|
|
9
|
+
import io
|
|
10
|
+
import heapq
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def compress_files(local_path, local_files, algorithm):
|
|
14
|
+
packed_content = _pack_files(local_path, local_files)
|
|
15
|
+
return compress_content(packed_content, algorithm)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def decompress_files(local_path, compressed_content, algorithm):
|
|
19
|
+
decompressed_content = decompress_content(compressed_content, algorithm)
|
|
20
|
+
_unpack_files(local_path, decompressed_content)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pack_files(local_path, local_files):
|
|
24
|
+
packed_content = io.StringIO()
|
|
25
|
+
for file_path, file_hash in local_files.items():
|
|
26
|
+
full_path = os.path.join(local_path, file_path)
|
|
27
|
+
with open(full_path, "r", encoding="utf-8") as f:
|
|
28
|
+
content = f.read()
|
|
29
|
+
packed_content.write(f"--- BEGIN FILE: {file_path} ---\n")
|
|
30
|
+
packed_content.write(content)
|
|
31
|
+
packed_content.write(f"\n--- END FILE: {file_path} ---\n")
|
|
32
|
+
return packed_content.getvalue()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _unpack_files(local_path, decompressed_content):
|
|
36
|
+
current_file = None
|
|
37
|
+
current_content = io.StringIO()
|
|
38
|
+
|
|
39
|
+
for line in decompressed_content.splitlines():
|
|
40
|
+
if line.startswith("--- BEGIN FILE:"):
|
|
41
|
+
if current_file:
|
|
42
|
+
_write_file(local_path, current_file, current_content.getvalue())
|
|
43
|
+
current_content = io.StringIO()
|
|
44
|
+
current_file = line.split("--- BEGIN FILE:")[1].strip()
|
|
45
|
+
elif line.startswith("--- END FILE:"):
|
|
46
|
+
if current_file:
|
|
47
|
+
_write_file(local_path, current_file, current_content.getvalue())
|
|
48
|
+
current_file = None
|
|
49
|
+
current_content = io.StringIO()
|
|
50
|
+
else:
|
|
51
|
+
current_content.write(line + "\n")
|
|
52
|
+
|
|
53
|
+
if current_file:
|
|
54
|
+
_write_file(local_path, current_file, current_content.getvalue())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write_file(local_path, file_path, content):
|
|
58
|
+
full_path = os.path.join(local_path, file_path)
|
|
59
|
+
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
60
|
+
with open(full_path, "w", encoding="utf-8") as f:
|
|
61
|
+
f.write(content)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compress_content(content, algorithm):
|
|
65
|
+
compressors = {
|
|
66
|
+
"zlib": zlib_compress,
|
|
67
|
+
"bz2": bz2_compress,
|
|
68
|
+
"lzma": lzma_compress,
|
|
69
|
+
"brotli": brotli_compress, # Add Brotli to compressors
|
|
70
|
+
"dictionary": dictionary_compress,
|
|
71
|
+
"rle": rle_compress,
|
|
72
|
+
"huffman": huffman_compress,
|
|
73
|
+
"lzw": lzw_compress,
|
|
74
|
+
"pack": no_compress,
|
|
75
|
+
}
|
|
76
|
+
if algorithm in compressors:
|
|
77
|
+
return compressors[algorithm](content)
|
|
78
|
+
else:
|
|
79
|
+
return content # No compression
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def decompress_content(compressed_content, algorithm):
|
|
83
|
+
decompressors = {
|
|
84
|
+
"zlib": zlib_decompress,
|
|
85
|
+
"bz2": bz2_decompress,
|
|
86
|
+
"lzma": lzma_decompress,
|
|
87
|
+
"brotli": brotli_decompress, # Add Brotli to decompressors
|
|
88
|
+
"dictionary": dictionary_decompress,
|
|
89
|
+
"rle": rle_decompress,
|
|
90
|
+
"huffman": huffman_decompress,
|
|
91
|
+
"lzw": lzw_decompress,
|
|
92
|
+
"pack": no_decompress,
|
|
93
|
+
}
|
|
94
|
+
if algorithm in decompressors:
|
|
95
|
+
return decompressors[algorithm](compressed_content)
|
|
96
|
+
else:
|
|
97
|
+
return compressed_content # No decompression
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Pack compression
|
|
101
|
+
def no_compress(text):
|
|
102
|
+
return text
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def no_decompress(compressed_text):
|
|
106
|
+
return compressed_text
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Brotli compression
|
|
110
|
+
def brotli_compress(text):
|
|
111
|
+
compressed = brotli.compress(text.encode("utf-8"))
|
|
112
|
+
return base64.b64encode(compressed).decode("ascii")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def brotli_decompress(compressed_text):
|
|
116
|
+
decoded = base64.b64decode(compressed_text.encode("ascii"))
|
|
117
|
+
return brotli.decompress(decoded).decode("utf-8")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Zlib compression
|
|
121
|
+
def zlib_compress(text):
|
|
122
|
+
compressed = zlib.compress(text.encode("utf-8"))
|
|
123
|
+
return base64.b64encode(compressed).decode("ascii")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def zlib_decompress(compressed_text):
|
|
127
|
+
decoded = base64.b64decode(compressed_text.encode("ascii"))
|
|
128
|
+
return zlib.decompress(decoded).decode("utf-8")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# BZ2 compression
|
|
132
|
+
def bz2_compress(text):
|
|
133
|
+
compressed = bz2.compress(text.encode("utf-8"))
|
|
134
|
+
return base64.b64encode(compressed).decode("ascii")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def bz2_decompress(compressed_text):
|
|
138
|
+
decoded = base64.b64decode(compressed_text.encode("ascii"))
|
|
139
|
+
return bz2.decompress(decoded).decode("utf-8")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# LZMA compression
|
|
143
|
+
def lzma_compress(text):
|
|
144
|
+
compressed = lzma.compress(text.encode("utf-8"))
|
|
145
|
+
return base64.b64encode(compressed).decode("ascii")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def lzma_decompress(compressed_text):
|
|
149
|
+
decoded = base64.b64decode(compressed_text.encode("ascii"))
|
|
150
|
+
return lzma.decompress(decoded).decode("utf-8")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Dictionary-based compression
|
|
154
|
+
def dictionary_compress(text):
|
|
155
|
+
words = text.split()
|
|
156
|
+
dictionary = {}
|
|
157
|
+
compressed = []
|
|
158
|
+
|
|
159
|
+
for word in words:
|
|
160
|
+
if word not in dictionary:
|
|
161
|
+
dictionary[word] = str(len(dictionary))
|
|
162
|
+
compressed.append(dictionary[word])
|
|
163
|
+
|
|
164
|
+
return json.dumps({"dict": dictionary, "compressed": " ".join(compressed)})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def dictionary_decompress(compressed_text):
|
|
168
|
+
data = json.loads(compressed_text)
|
|
169
|
+
dictionary = {v: k for k, v in data["dict"].items()}
|
|
170
|
+
return " ".join(dictionary[token] for token in data["compressed"].split())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Run-length encoding (RLE)
|
|
174
|
+
def rle_compress(text):
|
|
175
|
+
compressed = []
|
|
176
|
+
count = 1
|
|
177
|
+
for i in range(1, len(text)):
|
|
178
|
+
if text[i] == text[i - 1]:
|
|
179
|
+
count += 1
|
|
180
|
+
else:
|
|
181
|
+
compressed.append((text[i - 1], count))
|
|
182
|
+
count = 1
|
|
183
|
+
compressed.append((text[-1], count))
|
|
184
|
+
return json.dumps(compressed)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def rle_decompress(compressed_text):
|
|
188
|
+
compressed = json.loads(compressed_text)
|
|
189
|
+
return "".join(char * count for char, count in compressed)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Huffman coding
|
|
193
|
+
class HuffmanNode:
|
|
194
|
+
def __init__(self, char, freq):
|
|
195
|
+
self.char = char
|
|
196
|
+
self.freq = freq
|
|
197
|
+
self.left = None
|
|
198
|
+
self.right = None
|
|
199
|
+
|
|
200
|
+
def __lt__(self, other):
|
|
201
|
+
return self.freq < other.freq
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def huffman_compress(text):
|
|
205
|
+
freq = Counter(text)
|
|
206
|
+
heap = [HuffmanNode(char, freq) for char, freq in freq.items()]
|
|
207
|
+
heapq.heapify(heap)
|
|
208
|
+
|
|
209
|
+
while len(heap) > 1:
|
|
210
|
+
left = heapq.heappop(heap)
|
|
211
|
+
right = heapq.heappop(heap)
|
|
212
|
+
merged = HuffmanNode(None, left.freq + right.freq)
|
|
213
|
+
merged.left = left
|
|
214
|
+
merged.right = right
|
|
215
|
+
heapq.heappush(heap, merged)
|
|
216
|
+
|
|
217
|
+
root = heap[0]
|
|
218
|
+
codes = {}
|
|
219
|
+
|
|
220
|
+
def generate_codes(node, code):
|
|
221
|
+
if node.char:
|
|
222
|
+
codes[node.char] = code
|
|
223
|
+
return
|
|
224
|
+
generate_codes(node.left, code + "0")
|
|
225
|
+
generate_codes(node.right, code + "1")
|
|
226
|
+
|
|
227
|
+
generate_codes(root, "")
|
|
228
|
+
|
|
229
|
+
encoded = "".join(codes[char] for char in text)
|
|
230
|
+
padding = 8 - len(encoded) % 8
|
|
231
|
+
encoded += "0" * padding
|
|
232
|
+
|
|
233
|
+
compressed = bytearray()
|
|
234
|
+
for i in range(0, len(encoded), 8):
|
|
235
|
+
byte = encoded[i : i + 8]
|
|
236
|
+
compressed.append(int(byte, 2))
|
|
237
|
+
|
|
238
|
+
return json.dumps(
|
|
239
|
+
{
|
|
240
|
+
"tree": {char: code for char, code in codes.items()},
|
|
241
|
+
"padding": padding,
|
|
242
|
+
"data": base64.b64encode(compressed).decode("ascii"),
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def huffman_decompress(compressed_text):
|
|
248
|
+
data = json.loads(compressed_text)
|
|
249
|
+
tree = {code: char for char, code in data["tree"].items()}
|
|
250
|
+
padding = data["padding"]
|
|
251
|
+
compressed = base64.b64decode(data["data"].encode("ascii"))
|
|
252
|
+
|
|
253
|
+
binary = "".join(f"{byte:08b}" for byte in compressed)
|
|
254
|
+
binary = binary[:-padding] if padding else binary
|
|
255
|
+
|
|
256
|
+
decoded = ""
|
|
257
|
+
code = ""
|
|
258
|
+
for bit in binary:
|
|
259
|
+
code += bit
|
|
260
|
+
if code in tree:
|
|
261
|
+
decoded += tree[code]
|
|
262
|
+
code = ""
|
|
263
|
+
|
|
264
|
+
return decoded
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# LZW compression
|
|
268
|
+
def lzw_compress(text):
|
|
269
|
+
dictionary = {chr(i): i for i in range(256)}
|
|
270
|
+
result = []
|
|
271
|
+
w = ""
|
|
272
|
+
for c in text:
|
|
273
|
+
wc = w + c
|
|
274
|
+
if wc in dictionary:
|
|
275
|
+
w = wc
|
|
276
|
+
else:
|
|
277
|
+
result.append(dictionary[w])
|
|
278
|
+
dictionary[wc] = len(dictionary)
|
|
279
|
+
w = c
|
|
280
|
+
if w:
|
|
281
|
+
result.append(dictionary[w])
|
|
282
|
+
return base64.b64encode(bytes(result)).decode("ascii")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def lzw_decompress(compressed_text):
|
|
286
|
+
compressed = base64.b64decode(compressed_text.encode("ascii"))
|
|
287
|
+
dictionary = {i: chr(i) for i in range(256)}
|
|
288
|
+
result = []
|
|
289
|
+
w = chr(compressed[0])
|
|
290
|
+
result.append(w)
|
|
291
|
+
for i in range(1, len(compressed)):
|
|
292
|
+
k = compressed[i]
|
|
293
|
+
if k in dictionary:
|
|
294
|
+
entry = dictionary[k]
|
|
295
|
+
elif k == len(dictionary):
|
|
296
|
+
entry = w + w[0]
|
|
297
|
+
else:
|
|
298
|
+
raise ValueError("Bad compressed k: %s" % k)
|
|
299
|
+
result.append(entry)
|
|
300
|
+
dictionary[len(dictionary)] = w + entry[0]
|
|
301
|
+
w = entry
|
|
302
|
+
return "".join(result)
|