Skip to content

Commit

Permalink
feat: Finish method for creating and uploading a user file.
Browse files Browse the repository at this point in the history
  • Loading branch information
bvanelli committed Mar 12, 2024
1 parent 7bed6e3 commit d9ff57c
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 27 deletions.
79 changes: 66 additions & 13 deletions actual/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

import contextlib
import io
import json
import pathlib
import re
import sqlite3
import tempfile
import uuid
import zipfile
from typing import TYPE_CHECKING, List, Union

Expand All @@ -20,7 +24,6 @@
get_class_by_table_name,
)
from actual.exceptions import InvalidZipFile, UnknownFileId
from actual.models import RemoteFile
from actual.protobuf_models import Message, SyncRequest

if TYPE_CHECKING:
Expand Down Expand Up @@ -51,21 +54,23 @@ def __init__(
be created instead.
"""
super().__init__(base_url, token, password)
self._file: RemoteFile | None = None
self._data_dir = pathlib.Path(data_dir)
self._file: RemoteFileListDTO | None = None
self._data_dir = pathlib.Path(data_dir) if data_dir else None
self._session_maker = None
self._session: sqlalchemy.orm.Session | None = None
# set the correct file
if file:
self.set_file(file)

def __enter__(self) -> Actual:
self.download_budget()
self._session = self._session_maker()
if self._file:
self.download_budget()
self._session = self._session_maker()
return self

def __exit__(self, exc_type, exc_val, exc_tb):
self._session.close()
if self._session:
self._session.close()

@contextlib.contextmanager
def with_session(self) -> sqlalchemy.orm.Session:
Expand All @@ -89,16 +94,55 @@ def set_file(self, file_id: Union[str, RemoteFileListDTO]) -> RemoteFileListDTO:
return self.set_file(file)
raise UnknownFileId(f"Could not find a file id or identifier '{file_id}'")

def upload_file(self):
def create_budget(self, budget_name: str):
"""Creates a budget using the remote server default database and migrations."""
migration_files = self.data_file_index()
# create folder for the files
if not self._data_dir:
self._data_dir = pathlib.Path(tempfile.mkdtemp())
# first migration file is the default database
migration = self.data_file(migration_files[0])
(self._data_dir / "db.sqlite").write_bytes(migration)
# also write the metadata file with default fields
random_id = str(uuid.uuid4()).replace("-", "")[:7]
file_id = str(uuid.uuid4())
(self._data_dir / "metadata.json").write_text(
json.dumps(
{
"id": f"My-Finances-{random_id}",
"budgetName": budget_name,
"userId": self._token,
"cloudFileId": file_id,
"resetClock": True,
}
)
)
self._file = RemoteFileListDTO(name=budget_name, fileId=file_id, groupId=None, deleted=0, encryptKeyId=None)
# create engine for downloaded database and run migrations
conn = sqlite3.connect(self._data_dir / "db.sqlite")
for file in migration_files[1:]:
file_id = file.split("_")[0].split("/")[1]
migration = self.data_file(file)
sql_statements = migration.decode()
if file.endswith(".js"):
# there is one migration which is Javascript. All entries inside db.execQuery(`...`) must be executed
exec_entries = re.findall(r"db\.execQuery\(`([^`]*)`\)", sql_statements, re.DOTALL)
sql_statements = "\n".join(exec_entries)
conn.executescript(sql_statements)
conn.execute(f"INSERT INTO __migrations__ (id) VALUES ({file_id});")
conn.commit()
conn.close()

def upload_budget(self):
"""Uploads the current file to the Actual server."""
if not self._data_dir:
raise UnknownFileId("No current file loaded.")
binary_data = io.BytesIO()
z = zipfile.ZipFile(binary_data)
z.write(self._data_dir / "db.sqlite", "db.sqlite")
z.write(self._data_dir / "metadata.json", "metadata.json")
binary_data.seek(0)
return self.upload_user_file(binary_data.read(), self._file.name)
with zipfile.ZipFile(binary_data, "a", zipfile.ZIP_DEFLATED, False) as z:
z.write(self._data_dir / "db.sqlite", "db.sqlite")
z.write(self._data_dir / "metadata.json", "metadata.json")
binary_data.seek(0)
return self.upload_user_file(binary_data.getvalue(), self._file.file_id, self._file.name)

def apply_changes(self, messages: list[Message]):
"""Applies a list of sync changes, based on what the sync method returned on the remote."""
Expand All @@ -107,7 +151,10 @@ def apply_changes(self, messages: list[Message]):
with self.with_session() as s:
for message in messages:
if message.dataset == "prefs":
# ignore because it's an internal preference from actual
# write it to metadata.json instead
config = json.loads((self._data_dir / "metadata.json").read_text() or "{}")
config[message.row] = message.get_value()
(self._data_dir / "metadata.json").write_text(json.dumps(config))
continue
table = get_class_by_table_name(message.dataset)
entry = s.query(table).get(message.row)
Expand Down Expand Up @@ -139,6 +186,12 @@ def download_budget(self):
changes = self.sync(request)
self.apply_changes(changes.get_messages())

def load_clock(self):
"""See implementation at:
https://github.com/actualbudget/actual/blob/5bcfc71be67c6e7b7c8b444e4c4f60da9ea9fdaa/packages/loot-core/src/server/db/index.ts#L81-L98
"""
pass

def get_transactions(self) -> List[Transactions]:
with self._session_maker() as s:
query = (
Expand Down
44 changes: 41 additions & 3 deletions actual/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import requests
from pydantic import BaseModel, Field

from actual import SyncRequest, SyncResponse
from actual.exceptions import AuthorizationError, UnknownFileId
from actual.protobuf_models import SyncRequest, SyncResponse


class Endpoints(enum.Enum):
Expand All @@ -18,6 +18,7 @@ class Endpoints(enum.Enum):
SYNC = "sync/sync"
LIST_USER_FILES = "sync/list-user-files"
GET_USER_FILE_INFO = "sync/get-user-file-info"
UPDATE_USER_FILE_NAME = "sync/update-user-file-name"
DOWNLOAD_USER_FILE = "sync/download-user-file"
UPLOAD_USER_FILE = "sync/upload-user-file"
RESET_USER_FILE = "sync/reset-user-file"
Expand Down Expand Up @@ -107,6 +108,14 @@ class InfoDTO(BaseModel):
build: BuildDTO


class IsBootstrapedDTO(BaseModel):
bootstrapped: bool


class BootstrapInfoDTO(StatusDTO):
data: IsBootstrapedDTO


class ActualServer:
def __init__(
self,
Expand Down Expand Up @@ -158,6 +167,24 @@ def validate(self) -> ValidateDTO:
response.raise_for_status()
return ValidateDTO.parse_obj(response.json())

def needs_bootstrap(self) -> BootstrapInfoDTO:
"""Checks if the Actual needs bootstrap, in other words, if it needs a master password for the server."""
response = requests.get(f"{self.api_url}/{Endpoints.NEEDS_BOOTSTRAP}")
response.raise_for_status()
return BootstrapInfoDTO.parse_obj(response.json())

def data_file_index(self) -> List[str]:
"""Gets all the migration file references for the actual server."""
response = requests.get(f"{self.api_url}/{Endpoints.DATA_FILE_INDEX}")
response.raise_for_status()
return response.content.decode().splitlines()

def data_file(self, file_path: str) -> bytes:
"""Gets the content of the individual migration file from server."""
response = requests.get(f"{self.api_url}/data/{file_path}")
response.raise_for_status()
return response.content

def reset_user_file(self, file_id: str) -> StatusDTO:
"""Resets the file. If the file_id is not provided, the current file set is reset. Usually used together with
the upload_user_file() method."""
Expand All @@ -177,14 +204,15 @@ def download_user_file(self, file_id: str) -> bytes:
db.raise_for_status()
return db.content

def upload_user_file(self, binary_data: bytes, file_name: str = "My Finances") -> UploadUserFileDTO:
def upload_user_file(self, binary_data: bytes, file_id: str, file_name: str = "My Finances") -> UploadUserFileDTO:
"""Uploads the binary data, which is a zip folder containing the `db.sqlite` and the `metadata.json`."""
request = requests.post(
f"{self.api_url}/{Endpoints.UPLOAD_USER_FILE}",
data=binary_data,
headers=self.headers(
extra_headers={
"X-ACTUAL-FORMAT": 2,
"X-ACTUAL-FORMAT": "2",
"X-ACTUAL-FILE-ID": file_id,
"X-ACTUAL-NAME": file_name,
"Content-Type": "application/encrypted-file",
}
Expand All @@ -206,6 +234,16 @@ def get_user_file_info(self, file_id: str) -> RemoteFileDTO:
response.raise_for_status()
return RemoteFileDTO.parse_obj(response.json())

def update_user_file_name(self, file_id: str, file_name: str) -> StatusDTO:
"""Updates the file name for the budget on the remote server."""
response = requests.post(
f"{self.api_url}/{Endpoints.UPDATE_USER_FILE_NAME}",
json={"fileId": file_id, "name": file_name, "token": self._token},
headers=self.headers(),
)
response.raise_for_status()
return StatusDTO.parse_obj(response.json())

def user_get_key(self, file_id: str) -> UserGetKeyDTO:
"""Gets the key information associated with a user file, including the algorithm, key, salt and iv."""
response = requests.get(
Expand Down
11 changes: 0 additions & 11 deletions actual/models.py

This file was deleted.

0 comments on commit d9ff57c

Please sign in to comment.