Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Native support of Airtable #2

Closed
IAmVigneswaran opened this issue Feb 25, 2023 · 27 comments
Closed

Native support of Airtable #2

IAmVigneswaran opened this issue Feb 25, 2023 · 27 comments
Assignees
Labels
airlift Airlift Related enhancement New Feature Or Request
Milestone

Comments

@IAmVigneswaran
Copy link
Contributor

IAmVigneswaran commented Feb 25, 2023

https://github.com/academypoa/AirtableKit

TheAcharya/MarkersExtractor#44

@IAmVigneswaran IAmVigneswaran added the enhancement New Feature Or Request label Feb 25, 2023
@IAmVigneswaran IAmVigneswaran added this to the 1.1.0 milestone Feb 25, 2023
@IAmVigneswaran IAmVigneswaran added the airlift Airlift Related label Nov 5, 2023
@IAmVigneswaran
Copy link
Contributor Author

@milanvarady We now have Airlift!

https://github.com/TheAcharya/Airlift

We can use the pre-complied binary.

@IAmVigneswaran
Copy link
Contributor Author

@milanvarady For Airtable it is little more involved due to the integration of Dropbox for temporary image hosting provider. I believe it would work elegantly once it is implement correctly.

I have designed the mock-up ui for the Airtable tab within the Database panel. You can follow the UI layout. Tweak it a little if needed.9:

Marker Data GUI_Database_Airtable

TOOL_PATH="/Applications/Marker Data.app/Contents/Resources/airlift"
AIRTABLE_TOKEN="REPLACE"
AIRTABLE_BASE="REPLACE"
AIRTABLE_TABLE="REPLACE"
DROPBOX_TOKEN="Users/UserID/Library/Application Support/Marker Data/Database/dropbox-token.json"
UPLOAD_PAYLOAD="REPLACE"
UPLOAD_LOG="/Users/UserID/Library/Application Support/Marker Data/Logs/airlift_log.txt"

$TOOL_PATH --token $AIRTABLE_TOKEN --base $AIRTABLE_BASE --table $AIRTABLE_TABLE --dropbox-token $DROPBOX_TOKEN --attachment-columns-map "Image Filename" "Attachments" --md --log $UPLOAD_LOG --verbose "$UPLOAD_PAYLOAD"

For the Dropbox Token's location, we can fix it at -

/Users/UserID/Library/Application Support/Marker Data/Database/dropbox-token.json

Here is the workflow.

  1. Based on the instructions provided, users first would obtain dropbox App key.
  2. They would paste it in to Dropbox App Key field.
  3. User will click on the pencil icon.
  4. Our Marker Data app would create dropbox-token.json file within /Users/UserID/Library/Application Support/Marker Data/Database/ folder.
{
  "app_key": "VALUE"
}
  1. User would then press on the cloud-link icon. Marker data would trigger this shell command in the background.
TOOL_PATH="/Applications/Marker Data.app/Contents/Resources/airlift"
DROPBOX_TOKEN="/Users/UserID/Library/Application Support/Marker Data/Database/dropbox-token.json"
UPLOAD_LOG="/Users/UserID/Library/Application Support/Marker Data/Logs/airlift_log.txt"

$TOOL_PATH --dropbox-token $DROPBOX_TOKEN --dropbox-refresh-token --log $UPLOAD_LOG --verbose
  1. Marker Data would read the URL value from the Shell and put it as a link in our GUI. For example in the shell terminal it would print out as
1. Go to: https://www.dropbox.com/oauth2/authorize?response_type=code&client_id=6zh18qgnw37ifpp&token_access_type=offline&code_challenge=TphrwcwmRtkGawgxFvWQcROFMbjsTeba9BGv0Lgi0nw&code_challenge_method=S256
2. Click "Allow" (you might have to log in first).
3. Copy the authorization code.
Enter the authorization code here:  

We need to take this URL and direct to the link text within this "Click on this link sentence. You can disable when no link is created.

  1. Users would click on the link, their browser will get launched. After clicking "Allow", they would get the Authorization Code.
  2. User could paste the Authorization Code in the next field.
  3. User would press on the key button. Marker Data would basically copy the value in the background and paste it in to the shell and it would press enter.
  4. Airlift in the background would create and update the dropbox-token.json file.
{
  "app_key": "VALUE",
  "refresh_token": "VALUE"
}
  1. Once "refresh_token" parameter is created within dropbox-token.json file. The DOT would turn GREEN. Initial state would be in RED.

User would always use the same dropbox-token.json for different Airtable Database Profile. But they can always delete / Revoke their Dropbox App Key at any time for security reasons and Create a new Dropbox App Key. Once created, they will click on the pencil button and go through the steps again.

Hope the workflow makes sense to you. Thank you.

@IAmVigneswaran
Copy link
Contributor Author

IAmVigneswaran commented Dec 21, 2023

@milanvarady I forgot to include one more field for the Airtable Tab. With this field users are given more customisation to their database. They are not confined to just "Marker ID" as their main Key Column.

Marker Data GUI_Preference_Database_Airtable_Rename_Key Column

TOOL_PATH="/Applications/Marker Data.app/Contents/Resources/airlift"
AIRTABLE_TOKEN="REPLACE"
AIRTABLE_BASE="REPLACE"
AIRTABLE_TABLE="REPLACE"
DROPBOX_TOKEN="Users/UserID/Library/Application Support/Marker Data/Database/dropbox-token.json"
UPLOAD_PAYLOAD="REPLACE"
UPLOAD_LOG="/Users/UserID/Library/Application Support/Marker Data/Logs/airlift_log.txt"

$TOOL_PATH --token $AIRTABLE_TOKEN --base $AIRTABLE_BASE --table $AIRTABLE_TABLE --dropbox-token $DROPBOX_TOKEN --attachment-columns-map "Image Filename" "Attachments" --md --rename-key-column "Marker ID" "VALUE" --log $UPLOAD_LOG --verbose "$UPLOAD_PAYLOAD"

We now have an additional switch --rename-key-column "Marker ID" "VALUE". We keep "Marker ID" as fixed within --rename-key-column "Marker ID" "VALUE" switch. And we take the Value from the Rename Key Column field.

If the field is empty, we don't use this --rename-key-column "Marker ID" "VALUE".

Hope it makes sense.

@IAmVigneswaran
Copy link
Contributor Author

@milanvarady I believe we can remove AirtableKit from our dependencies. Since we are using Airlift, we won't be needing AirtableKit.

D5F74A472972FE7C00EEE2FE /* AirtableKit in Frameworks */,

D5F74A472972FE7C00EEE2FE /* AirtableKit in Frameworks */ = {isa = PBXBuildFile; productRef = D5F74A462972FE7C00EEE2FE /* AirtableKit */; };

D5F74A462972FE7C00EEE2FE /* AirtableKit */,

@IAmVigneswaran IAmVigneswaran pinned this issue Jan 22, 2024
@milanvarady
Copy link
Contributor

milanvarady commented Feb 8, 2024

@IAmVigneswaran I've started the Airtable implementation, however, there are two problems.

1. Airlift is not recognizing the command line arguments when trying to get the authorization URL. I used the ones you described:

TOOL_PATH="/Applications/Marker Data.app/Contents/Resources/airlift"
DROPBOX_TOKEN="/Users/UserID/Library/Application Support/Marker Data/Database/dropbox-token.json"
UPLOAD_LOG="/Users/UserID/Library/Application Support/Marker Data/Logs/airlift_log.txt"

$TOOL_PATH --dropbox-token $DROPBOX_TOKEN --dropbox-refresh-token --log $UPLOAD_LOG --verbose

The output is:

usage: airlift [-h] --token TOKEN --base BASE --table TABLE [OPTION]... FILE
airlift: error: the following arguments are required: --token, --base, --table

Edit: I was testing this with Airlift v1.0.6.

2. In step 9 you mention:

User would press on the key button. Marker Data would basically copy the value in the background and paste it in to the shell and it would press enter.

I can't pass data into the running shell like a user would. I can only call a command with arguments. So could the process be modified so there is a separate command to get the auth URL and to submit the authorization code?

@IAmVigneswaran
Copy link
Contributor Author

IAmVigneswaran commented Feb 9, 2024

@milanvarady Apparently there is a bug in the latest build of Airlift creating dropbox refresh token. Hopefully we can fix this asap. TheAcharya/Airlift#31

I can't pass data into the running shell like a user would. I can only call a command with arguments. So could the process be modified so there is a separate command to get the auth URL and to submit the authorization code?

@arjunprakash027 Can we add this additional switch? Maybe once we have fixed this, we would have a clearer picture on the workflow.

@IAmVigneswaran
Copy link
Contributor Author

IAmVigneswaran commented Feb 9, 2024

@milanvarady We have now fixed this.

You can use this test build binary for now.

https://www.dropbox.com/scl/fi/iatymkhju3n552ntey9uy/release_dist_bin_macos.zip?rlkey=v3mfs075bnfbirbtua0da4qpc&dl=0

That should solve the first problem.


I can't pass data into the running shell like a user would. I can only call a command with arguments. So could the process be modified so there is a separate command to get the auth URL and to submit the authorization code?

For this issue. what do you have in mind?

TheAcharya/Airlift#32

Right now using this,

TOOL_PATH="/Applications/Marker Data.app/Contents/Resources/airlift"
DROPBOX_TOKEN="/Users/UserID/Library/Application Support/Marker Data/Database/dropbox-token.json"
UPLOAD_LOG="/Users/UserID/Library/Application Support/Marker Data/Logs/airlift_log.txt"

$TOOL_PATH --dropbox-token $DROPBOX_TOKEN --dropbox-refresh-token --log $UPLOAD_LOG --verbose

Would show this in terminal -

1. Go to: https://www.dropbox.com/oauth2/authorize?response_type=code&client_id=6zh18qgnw37ifpp&token_access_type=offline&code_challenge=TphrwcwmRtkGawgxFvWQcROFMbjsTeba9BGv0Lgi0nw&code_challenge_method=S256
2. Click "Allow" (you might have to log in first).
3. Copy the authorization code.
Enter the authorization code here:  

Proposed -

$TOOL_PATH --dropbox-token $DROPBOX_TOKEN --dropbox-refresh-token-authorization "CODE" --log $UPLOAD_LOG --verbose

So we need to have something like --dropbox-refresh-token-authorization "CODE" ?

After users have obtained their authorization code, they would paste into the form, Marker Data would take the value and trigger the shell script to update the dropbox-token.json file.

@milanvarady
Copy link
Contributor

milanvarady commented Feb 9, 2024

So we need to have something like --dropbox-refresh-token-authorization "CODE"?

@IAmVigneswaran Exactly. I need two additional options. One for just returning the auth URL. So like --dropbox-get-auth-url $APP_KEY. And --dropbox-refresh-token $CODE that would update the JSON. And it would be nice if these would just return the info I need without any additional text so I don't have to manually extract the necessary pieces of data.

@arjunprakash027
Copy link

arjunprakash027 commented Feb 9, 2024

Creating 2 seperate processes one for getting the url and one for entering the code is possible, but the caveat here is, to get the code one must go to the url and press on "allow" button, so dropbox knows it is you who wants to get the code and give you the code. How are you planning to automate that (if you are planning) @milanvarady

@IAmVigneswaran
Copy link
Contributor Author

Creating 2 seperate processes one for getting the url and one for entering the code is possible, but the caveat here is, to get the code one must go to the url and press on "allow" button, so dropbox knows it is you who wants to get the code and give you the code. How are you planning to automate that (if you are planning) @milanvarady

@arjunprakash027 There is no "automation". The idea is --dropbox-get-authorization-url-only "APPKEY" would only create and reset the json file and print the URL in the terminal. I believe @milanvarady would be able to copy the URL and launch default browser. User would have to click Allow to obtain the authorization code.

Once they have copied the code, they would paste it in the our Marker Data's Dropbox Authorization Code field. And they would click a button. And Marker Data would would trigger using --dropbox-refresh-token-input-authorization "CODE"

I believe we can also do this.

Step 1

TOOL_PATH="/Applications/Marker Data.app/Contents/Resources/airlift"
DROPBOX_TOKEN="/Users/UserID/Library/Application Support/Marker Data/Database/dropbox-token.json"
UPLOAD_LOG="/Users/UserID/Library/Application Support/Marker Data/Logs/airlift_log.txt"

$TOOL_PATH --dropbox-token $DROPBOX_TOKEN --dropbox-get-authorization-url-only "APPKEY" --log $UPLOAD_LOG --verbose

Airlift would basically RESET and Create a new JSON File.

{
  "app_key": "VALUE"
}

Step 2

TOOL_PATH="/Applications/Marker Data.app/Contents/Resources/airlift"
DROPBOX_TOKEN="/Users/UserID/Library/Application Support/Marker Data/Database/dropbox-token.json"
UPLOAD_LOG="/Users/UserID/Library/Application Support/Marker Data/Logs/airlift_log.txt"

$TOOL_PATH --dropbox-token $DROPBOX_TOKEN --dropbox-refresh-token-input-authorization "CODE --log $UPLOAD_LOG --verbose
{
  "app_key": "VALUE"
  "refresh_token": "VALUE"
}

@IAmVigneswaran
Copy link
Contributor Author

@milanvarady After experimenting, @arjunprakash027 has discovered that we can't able to split the process of the getting the URL first and then entering the authorization code separately.

The first step is getting the authorization URL from the Dropbox server and 2nd step is to send the auth code again to the server to get the refresh token. The catch here is, we have to create a SINGLE session to get the url and send the authorization code to get the refresh token. Every session is unique and authorization code received from one session cannot be passed to another session.

When we splitting our refresh-token step into 2 step process, we have to create 2 sessions because we cannot persists the session between 2 runs of the Airlift binary and therefore the code received from the first session of first step cannot be passed to the session created in 2nd step both must be done in the same step.

One possible solution, we do this obtaining of authorization URL, authorization code and updating the dropbox-token.json via swift?

Not sure if you can take ideas from our Airlift code.

https://www.dropbox.com/developers/documentation/swift#overview
https://github.com/dropbox/SwiftyDropbox?tab=readme-ov-file#obtain-an-oauth-20-token

Or find a way to pass the authorization code back in to the running background shell? Which you previously mentioned not possible?

https://stackoverflow.com/questions/74501866/how-to-launch-terminal-and-pass-it-a-command-using-swift
https://www.tekramer.com/observing-real-time-ouput-from-shell-commands-in-a-swift-script
https://scriptingosx.com/2023/08/build-a-macos-application-to-run-a-shell-command-with-xcode-and-swiftui-part-2/

@IAmVigneswaran
Copy link
Contributor Author

IAmVigneswaran commented Feb 10, 2024

@milanvarady Not sure if this approach can solve our problem.

Airtable-Form with Terminal

We basically have a expose small terminal window within our Airtable form. When users click on the Cloud button, it would trigger -

TOOL_PATH="/Applications/Marker Data.app/Contents/Resources/airlift"
DROPBOX_TOKEN="/Users/UserID/Library/Application Support/Marker Data/Database/dropbox-token.json"
UPLOAD_LOG="/Users/UserID/Library/Application Support/Marker Data/Logs/airlift_log.txt"

$TOOL_PATH --dropbox-token $DROPBOX_TOKEN --dropbox-refresh-token --log $UPLOAD_LOG --verbose

https://stackoverflow.com/questions/59227688/swift-view-and-use-terminal-inside-another-application
https://github.com/migueldeicaza/SwiftTerm

Not entirely sleek, but I believe it would work?

I have seen Apps that has Terminal Emulator embedded into their UI.

You need not have to add any additional swift code for dropbox swift SDK.

From Chat GPT

import SwiftUI

struct TerminalView: View {
    @State private var terminalOutput = ""
    @State private var command = ""

    var body: some View {
        VStack {
            Text("Simple Terminal Emulator")

            ScrollView {
                TextEditor(text: $terminalOutput)
                    .frame(minWidth: 200, minHeight: 100)
                    .background(Color.black)
                    .foregroundColor(Color.green)
                    .font(.system(size: 12))
                    .disabled(true)
            }

            TextField("Enter command", text: $command, onCommit: executeCommand)
                .padding()
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }
        .padding()
    }

    func executeCommand() {
        // Execute the command and update terminal output
        terminalOutput += "> \(command)\n"
        // Add logic to execute the command and get output
        let commandOutput = executeShellCommand(command)
        terminalOutput += "\(commandOutput)\n"
        command = ""
    }

    func executeShellCommand(_ command: String) -> String {
        // Add logic to execute the shell command and get output
        // This is just a placeholder, replace it with actual command execution code
        return "Output for command: \(command)"
    }
}

In this example:

NSTextView is used to display the terminal output.
TextField is used to input commands.
executeCommand function is called when the user presses enter after typing a command. This function should execute the command and update the terminal output accordingly.
executeShellCommand is a placeholder function that should be replaced with the actual code to execute shell commands and return their output.

@milanvarady
Copy link
Contributor

@IAmVigneswaran This is getting overly complicated. I don't know of any way to include a terminal inside the app, and the code you provided is not a real implementation. I think we have two reasonable options:

1. Replace Dropbox entirely

The best would be to replace Dropbox entirely. Dropbox is not really meant for public HTTP access. The workflow to achieve this is very convoluted.

Amazon's S3 file storage service IS meant for public access. It's inexpensive. We could either use an S3 bucket that all clients would use - as long as you don't have thousands of users, this should cost on the order of a few dollars per month maximum.

Or, users could register their own S3 (or compatible) account and provide their own access keys, then you wouldn't need to worry about the storage costs.

Amazon S3
Cloudflare R2

2. Setup outside of the app

Another solution is to keep Dropbox but set it up outside of Marker Data in the terminal. In the documentation, you can provide the steps users can follow to set up Dropbox.

@IAmVigneswaran
Copy link
Contributor Author

IAmVigneswaran commented Feb 10, 2024

@milanvarady Thanks for input.

Option 1 - Is not possible. After heavy review of all the alternative storage providers, Dropbox is still best free option. There is no cost involved for the end user.

Option 2 - Yes. It is possible but as a FINAL last resort. When all else fails. We have spent so much of effort and energy on our Application, asking users to up terminal and type commands is still not the best approach.

@IAmVigneswaran
Copy link
Contributor Author

IAmVigneswaran commented Feb 10, 2024

@milanvarady While we agreed on opening a terminal as a separate window would be the best approach. (Last Resort)

I was wondering if we can just attempt on embedding the terminal? Just a couple of tries. 🙏

From ChatGPT

import SwiftUI

struct TerminalView: NSViewControllerRepresentable {
    @Binding var terminalText: String
    
    var width: CGFloat
    var height: CGFloat
    
    func makeNSViewController(context: Context) -> NSViewController {
        let viewController = NSViewController()
        let textView = NSTextView()
        textView.isEditable = true // Allow user input
        textView.backgroundColor = NSColor.black
        textView.textColor = NSColor.green
        viewController.view = textView
        return viewController
    }
    
    func updateNSViewController(_ nsViewController: NSViewController, context: Context) {
        guard let textView = nsViewController.view as? NSTextView else { return }
        textView.string = terminalText
    }
}

struct ContentView: View {
    @State private var terminalText: String = ""
    
    var body: some View {
        VStack {
            Text("Your App Content Here")
            TerminalView(terminalText: $terminalText, width: 400, height: 200)
            Button("Run Script") {
                executeScript()
            }.padding()
        }
    }
    
    func executeScript() {
        let scriptPath = "/Applications/Marker Data.app/Contents/Resources/dropbox-rehresh-token.sh"
        terminalText.append(">> \(scriptPath)\n")
        let task = Process()
        task.launchPath = "/bin/bash"
        task.arguments = ["-c", "sh \(scriptPath)"] // Add "sh" command here
        
        let pipe = Pipe()
        task.standardOutput = pipe
        
        task.launch()
        
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        if let output = String(data: data, encoding: .utf8) {
            terminalText.append(output)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
  • TerminalView is a NSViewControllerRepresentable that represents the embedded terminal.
  • The ContentView contains the embedded terminal view along with a TextField for entering commands and a Button to execute them.
  • When the user presses the execute button, the executeCommand function is called, which runs the entered command in the terminal and displays the output.
  • When the button is pressed, the executeScript function is called, which runs the shell script located at "/Applications/Marker Data.app/Contents/Resources/dropbox-rehresh-token.sh" using the sh command and displays the output in the embedded terminal.

@milanvarady
Copy link
Contributor

@IAmVigneswaran ChatGPT won't be able to help in this regard. Building a fully functional terminal emulator is a very complicated task, it's kind of like building a web browser. We might be able to find some package that does this, but I don't know how easy would it be to interact with it, especially from Swift. Like pasting in the command and so on.

@IAmVigneswaran
Copy link
Contributor Author

@milanvarady Thanks for the test build!

We need to tweak to the behaviour of how dropbox_token.json is created and updated.

The Dropbox Configured and Success Green text appears too early.

It should only appear when refresh_token's value is filled.

{
  "refresh_token" : "",
}

@IAmVigneswaran IAmVigneswaran unpinned this issue Feb 12, 2024
@milanvarady
Copy link
Contributor

@IAmVigneswaran I have merged the changes. Also, the checking logic has been fixed.

@IAmVigneswaran
Copy link
Contributor Author

@milanvarady The behaviour works correctly now. Thanks for the update!

For convince,

Can the Dropbox text be a link to open https://www.dropbox.com/developers/apps

@IAmVigneswaran
Copy link
Contributor Author

@milanvarady I notice that Rename Key Column is not working.

Airtable - Rename Key Column

But when I tested it directly with Airlift Binary. I am able to update the Data set to Airtable. I have share the Airtable Template with you.

Airtable - Rename Key Column-02
#!/bin/sh

TOOL_PATH="/Volumes/Ancestral Recall/App Projects/Airlift/airlift"
AIRTABLE_TOKEN="REPLACE"
AIRTABLE_BASE="REPLACE"
AIRTABLE_TABLE="REPLACE"
DROPBOX_TOKEN="REPLACE"
UPLOAD_PAYLOAD="REPLACE"
UPLOAD_LOG="REPLACE"

"$TOOL_PATH" --token $AIRTABLE_TOKEN --base $AIRTABLE_BASE --table $AIRTABLE_TABLE --dropbox-token "$DROPBOX_TOKEN" --attachment-columns-map "Image Filename" "Attachments" --md --rename-key-column "Marker ID" "Shot Name" --log "$UPLOAD_LOG" --verbose "$UPLOAD_PAYLOAD"

@milanvarady
Copy link
Contributor

Can the Dropbox text be a link to open https://www.dropbox.com/developers/apps

I added a link below instead. Making the title a link doesn't make sense to me.

CleanShot 2024-02-13 at 17 34 34

@milanvarady
Copy link
Contributor

I notice that Rename Key Column is not working.

I was passing in the wrong argument. Should be working now, although I get warnings like:

WARNING: Column Checked would be skipped!
WARNING: Column Marker Name would be skipped!
WARNING: Column Notes would be skipped!

And the data is not uploaded. Check if it works for you.

@IAmVigneswaran
Copy link
Contributor Author

I added a link below instead. Making the title a link doesn't make sense to me.

Perfect!

@IAmVigneswaran
Copy link
Contributor Author

Check if it works for you.

Just tested Rename Key Column, it works correctly now! Thanks for the fix.

And the data is not uploaded.

WARNING: Column Checked would be skipped!
WARNING: Column Marker Name would be skipped!
WARNING: Column Notes would be skipped!

It is normal behaviour. When any Column is not present or created in Airtable, Airlift binary would automatically skip the column. It would be not created.

@IAmVigneswaran
Copy link
Contributor Author

@milanvarady Airlift is now updated to 1.0.7.

Could you add --verbose switch as well. We have also added debug trace information into the log file when error occurs.

Is it possible to Kill and Close both of Terminal windows once the dropbox_token.json is updated successfully?

Thank you.

@milanvarady
Copy link
Contributor

Could you add --verbose switch as well. We have also added debug trace information into the log file when error occurs.

We already have the --verbose flag.

Is it possible to Kill and Close both of Terminal windows once the dropbox_token.json is updated successfully?

It is possible, but we don't know if Marker Data opened the terminal or if the user had it already open. Because if they did, quitting the app just like that wouldn't be good. What if they have some other important process running there?

@IAmVigneswaran
Copy link
Contributor Author

We already have the --verbose flag.

👍

Because if they did, quitting the app just like that wouldn't be good.

Noted!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
airlift Airlift Related enhancement New Feature Or Request
Projects
None yet
Development

No branches or pull requests

3 participants