diff --git a/src/public/conversion/index.html b/src/public/conversion/index.html
new file mode 100644
index 0000000..7f5b68a
--- /dev/null
+++ b/src/public/conversion/index.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+ Melies - Conversion
+
+
+
+
+
+
+
+
+
+
+
+
+
+
To convert
+
+
Director is empty
+
+
+
+
+
Agents
+
No registered agent
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/public/metadata/edit/index.html b/src/public/metadata/edit/index.html
index 7f90d6c..e5bf14c 100644
--- a/src/public/metadata/edit/index.html
+++ b/src/public/metadata/edit/index.html
@@ -6,6 +6,7 @@
Melies - Metadata Editor
+
diff --git a/src/public/static/css/conversion.css b/src/public/static/css/conversion.css
new file mode 100644
index 0000000..1553a58
--- /dev/null
+++ b/src/public/static/css/conversion.css
@@ -0,0 +1,265 @@
+body {
+ display: flex;
+ flex-direction: column;
+}
+
+main {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1em;
+ flex: 1;
+ overflow-y: hidden;
+}
+
+.panel {
+ display: flex;
+ flex-direction: column;
+ padding: 0.8em;
+ border: solid black 2px;
+ overflow: hidden;
+
+ &.hidden {
+ display: none;
+ }
+
+ .title {
+ margin: 0.4em 0;
+ }
+}
+
+button {
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0.4em 0.8em;
+ border-radius: 0.4em;
+ background: none;
+ cursor: pointer;
+ border: solid #c5c5c5 2px;
+ text-align: center;
+ font-weight: bold;
+
+ &:hover {
+ background-color: #f8f8f8;
+ }
+}
+
+#up {
+ width: 100%;
+ padding: 0.8em 1.6em;
+ border-radius: 1.2em;
+
+ &:not(.show) {
+ display: none;
+ }
+}
+
+#files {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, 12em);
+ grid-auto-rows: 12em;
+ gap: 0.8em;
+ place-items: center;
+ padding: 0.8em 0;
+ justify-content: space-evenly;
+ height: 100%;
+ overflow: auto;
+
+ .file {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+ height: 100%;
+ text-decoration: none;
+ color: black;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0.4em;
+ border-radius: 1.2em;
+ background: none;
+ cursor: pointer;
+ gap: 0.2em;
+ border: solid transparent 2px;
+
+ &.selected {
+ border-color: rgb(153, 226, 153);
+ }
+
+ &:hover {
+ background-color: #f8f8f8;
+ }
+
+ img {
+ width: 5em;
+ height: 5em;
+ }
+
+ .name {
+ overflow-wrap: anywhere;
+ text-align: center;
+ font-weight: bold;
+ flex-shrink: 1;
+ overflow: hidden;
+ }
+
+ .info, .children {
+ font-size: 80%;
+ color: rgb(75, 75, 75);
+ font-style: italic;
+ font-weight: normal;
+ }
+ }
+}
+
+#selected {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: auto;
+
+ .file {
+ display: flex;
+ padding: 0.4em 0.8em;
+ gap: 0.4em;
+ align-items: center;
+
+ &:nth-child(even) {
+ background-color: #fafafa;
+ }
+
+ .deselect {
+ background-color: #ffd8c6;
+
+ &:hover {
+ background-color: #ffc3a7;
+ }
+ }
+
+ .name {
+ overflow-wrap: anywhere;
+ }
+ }
+}
+
+.toolbar {
+ display: flex;
+ gap: 0.4em;
+
+ button, button .enabled {
+ display: flex;
+ gap: 0.2em;
+ align-items: center;
+
+ &.hidden {
+ display: none;
+ }
+
+ img {
+ width: 1.4em;
+ height: 1.4em;
+ }
+ }
+}
+
+.empty-msg {
+ color: #4d4d4d;
+ font-style: italic;
+ font-size: 120%;
+ text-align: center;
+ padding: 1.2em 0.4em;
+
+ &:not(.show) {
+ display: none;
+ }
+}
+
+#agents {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, 12em);
+ grid-auto-rows: 12em;
+ gap: 0.8em;
+ place-items: center;
+ padding: 0.8em 0;
+ justify-content: space-evenly;
+ height: 100%;
+ overflow: auto;
+
+ .agent {
+ input {
+ display: none;
+ }
+
+ .container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+ height: 100%;
+ text-decoration: none;
+ color: black;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0.4em;
+ border-radius: 1.2em;
+ background: none;
+ cursor: pointer;
+ gap: 0.2em;
+ border: none;
+
+ &.selected {
+ border-color: #99e299;
+ }
+
+ &:hover {
+ background-color: #f8f8f8;
+ }
+
+ .icon {
+ width: 8em;
+ height: 8em;
+ mask-image: url("/static/images/agent.svg");
+ mask-size: contain;
+ background-color: black;
+ }
+
+ .name {
+ overflow-wrap: anywhere;
+ text-align: center;
+ font-weight: bold;
+ font-size: 120%;
+ flex-shrink: 1;
+ overflow: hidden;
+ }
+ }
+
+ input:checked + .container {
+ .icon {
+ background-color: #1bb11b;
+ }
+
+ .name {
+ color: #1bb11b;
+ }
+ }
+ }
+}
+
+#convert {
+ font-size: 120%;
+}
+
+#continue, #convert {
+ &:not(:disabled) {
+ .disabled {
+ display: none;
+ }
+ }
+
+ &:disabled {
+ .enabled {
+ display: none;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/public/static/images/agent.svg b/src/public/static/images/agent.svg
new file mode 100644
index 0000000..5d2f0c4
--- /dev/null
+++ b/src/public/static/images/agent.svg
@@ -0,0 +1,498 @@
+
+
+
+
diff --git a/src/public/static/js/conversion.js b/src/public/static/js/conversion.js
new file mode 100644
index 0000000..8eecfec
--- /dev/null
+++ b/src/public/static/js/conversion.js
@@ -0,0 +1,231 @@
+let selected = []
+let currentPath = []
+
+const SIZES = ["", "K", "M", "G", "T"]
+
+function formatSize(bytes) {
+ let order = Math.floor(Math.log10(bytes) / 3)
+ if (bytes > Math.pow(10, (order + 1) * 3) / 2) {
+ order += 1
+ }
+ let size = bytes / Math.pow(10, order * 3)
+ size = Math.round(size * 10) / 10
+ const prefix = SIZES[order]
+ return `${size}${prefix}B`
+}
+
+function makeFolder(meta) {
+ const file = document.getElementById("folder-template").cloneNode(true)
+ file.querySelector(".name").innerText = meta.filename
+ file.querySelector(".children .num").innerText = meta.elements
+ file.addEventListener("dblclick", () => {
+ currentPath.push(meta.filename)
+ navigate()
+ })
+ return file
+}
+
+function makeMedia(meta) {
+ const file = document.getElementById("media-template").cloneNode(true)
+ file.querySelector(".name").innerText = meta.filename.split(".").slice(0, -1).join(".")
+ file.querySelector(".size").innerText = formatSize(meta.size)
+ file.querySelector(".ext").innerText = meta.filename.split(".").slice(-1)[0].toUpperCase()
+ return file
+}
+
+function makeFile(meta) {
+ let file
+ switch (meta.type) {
+ case "folder":
+ file = makeFolder(meta)
+ break
+ case "media":
+ file = makeMedia(meta)
+ break
+ default:
+ throw new Error(`Invalid file type '${meta.type}'`)
+ }
+
+ file.title = meta.filename
+ file.id = null
+ file.dataset.path = meta.path
+ file.classList.remove("template")
+ if (selected.includes(meta.path)) {
+ file.classList.add("selected")
+ }
+
+ file.addEventListener("click", e => {
+ if (e.ctrlKey) {
+ e.preventDefault()
+ toggleSelectFile(meta)
+ }
+ })
+ return file
+}
+
+function makeAgent(meta) {
+ const agent = document.getElementById("agent-template").cloneNode(true)
+ agent.classList.remove("template")
+ agent.removeAttribute("id")
+ agent.dataset.uuid = meta.uuid
+ agent.querySelector(".name").innerText = meta.name
+ const input = agent.querySelector("input[type='radio']")
+ input.value = meta.uuid
+ input.addEventListener("change", () => updateConvertBtn())
+ return agent
+}
+
+function toggleSelectFile(meta) {
+ if (selected.includes(meta.path)) {
+ deselectFile(meta.path)
+ } else {
+ selectFile(meta)
+ }
+}
+
+function selectFile(meta) {
+ selected.push(meta.path)
+ document.querySelector(`.file[data-path='${meta.path}']`)?.classList?.add("selected")
+ const list = document.getElementById("selected")
+ const line = document.getElementById("selected-template").cloneNode(true)
+ line.classList.remove("template")
+ line.id = null
+ line.querySelector(".name").innerText = meta.path
+ line.dataset.path = meta.path
+ line.querySelector(".deselect").addEventListener("click", () => {
+ deselectFile(meta.path)
+ })
+ list.appendChild(line)
+
+ const continueBtn = document.getElementById("continue")
+ continueBtn.disabled = false
+}
+
+function deselectFile(path) {
+ selected = selected.filter(p => p !== path)
+ document.querySelector(`#files .file[data-path='${path}']`)?.classList?.remove("selected")
+ document.querySelector(`#selected .file[data-path='${path}']`)?.remove()
+
+ const continueBtn = document.getElementById("continue")
+ continueBtn.disabled = selected.length === 0
+}
+
+function deselectAll() {
+ selected.forEach(path => {
+ deselectFile(path)
+ })
+ showFiles()
+}
+
+/**
+ *
+ * @param {object[]} files
+ */
+function addFiles(files) {
+ const emptyMsg = document.getElementById("no-file")
+ if (files.length === 0) {
+ emptyMsg.classList.add("show")
+ } else {
+ emptyMsg.classList.remove("show")
+ }
+ const list = document.getElementById("files")
+ const list2 = document.createElement("div")
+ list2.innerHTML = ""
+ const filenames = files.map(meta => meta.filename)
+ // Copy array because sort changes it in place
+ Array.from(filenames).sort().forEach(filename => {
+ const i = filenames.indexOf(filename)
+ const meta = files[i]
+ const file = makeFile(meta)
+ list2.appendChild(file)
+ })
+ list.replaceChildren(...list2.children)
+
+ const upBtn = document.getElementById("up")
+ if (currentPath.length === 0) {
+ upBtn.classList.remove("show")
+ } else {
+ upBtn.classList.add("show")
+ }
+}
+
+function navigate() {
+ const url = new URL("/api/files/to_convert", window.location.origin)
+ url.searchParams.set("f", currentPath.join("/"))
+
+ fetch(url.href).then(res => {
+ return res.json()
+ }).then(files => {
+ addFiles(files)
+ })
+}
+
+function showFiles() {
+ document.getElementById("show-files").classList.add("hidden")
+ document.getElementById("continue").classList.remove("hidden")
+ document.getElementById("agents-panel").classList.add("hidden")
+ document.getElementById("to-convert-panel").classList.remove("hidden")
+}
+
+function showAgents() {
+ document.getElementById("continue").classList.add("hidden")
+ document.getElementById("show-files").classList.remove("hidden")
+ document.getElementById("to-convert-panel").classList.add("hidden")
+ document.getElementById("agents-panel").classList.remove("hidden")
+}
+
+function updateConvertBtn() {
+ const agent = document.querySelector("#agents .agent input:checked")
+ const convertBtn = document.getElementById("convert")
+ if (agent) {
+ convertBtn.disabled = false
+ } else {
+ convertBtn.disabled = true
+ }
+}
+
+function addAgents(agents) {
+ const emptyMsg = document.getElementById("no-agent")
+ if (agents.length === 0) {
+ emptyMsg.classList.add("show")
+ } else {
+ emptyMsg.classList.remove("show")
+ }
+ const selectedAgent = document.querySelector("#agents .agent input:checked")?.parentElement
+
+ const list = document.getElementById("agents")
+ const list2 = document.createElement("div")
+ list2.innerHTML = ""
+ const agentNames = agents.map(meta => meta.name)
+ // Copy array because sort changes it in place
+ Array.from(agentNames).sort().forEach(name => {
+ const i = agentNames.indexOf(name)
+ const meta = agents[i]
+ const agent = makeAgent(meta)
+ if (selectedAgent?.dataset?.uuid === meta.uuid) {
+ agent.querySelector("input").checked = true
+ }
+ list2.appendChild(agent)
+ })
+ list.replaceChildren(...list2.children)
+ updateConvertBtn()
+}
+
+window.addEventListener("load", () => {
+ document.getElementById("up").addEventListener("dblclick", () => {
+ if (currentPath.length !== 0) {
+ currentPath = currentPath.slice(0, -1)
+ navigate()
+ }
+ })
+ document.getElementById("show-files").addEventListener("click", () => {
+ showFiles()
+ })
+ document.getElementById("deselect-all").addEventListener("click", () => {
+ deselectAll()
+ })
+ document.getElementById("continue").addEventListener("click", () => {
+ showAgents()
+ })
+ navigate()
+})
\ No newline at end of file
diff --git a/src/public/static/js/metadata.js b/src/public/static/js/metadata.js
index b264ef0..dd9afba 100644
--- a/src/public/static/js/metadata.js
+++ b/src/public/static/js/metadata.js
@@ -94,7 +94,7 @@ function sortFiles() {
}
window.addEventListener("load", () => {
- fetch("/api/files").then(res => {
+ fetch("/api/files/metadata").then(res => {
return res.json()
}).then(files => {
addFiles(files)
diff --git a/src/server.py b/src/server.py
index c21b47a..62144cd 100755
--- a/src/server.py
+++ b/src/server.py
@@ -41,11 +41,14 @@ class EnvDefault(argparse.Action):
class HTTPHandler(SimpleHTTPRequestHandler):
SERVER: MeliesServer = None
- CACHE = {}
+ METADATA_CACHE = {}
+ TO_CONVERT_CACHE = {}
def __init__(self, *args, **kwargs):
self.MAX_PAYLOAD_SIZE: int = self.SERVER.max_payload_size
- self.DATA_DIR: str = self.SERVER.metadata_dir
+ self.TO_CONVERT_DIR: str = self.SERVER.to_convert_dir
+ self.CONVERTED_DIR: str = self.SERVER.converted_dir
+ self.METADATA_DIR: str = self.SERVER.metadata_dir
super().__init__(
*args,
@@ -78,16 +81,20 @@ class HTTPHandler(SimpleHTTPRequestHandler):
return True
def do_GET(self):
- self.path = unquote(self.path)
- self.query = parse_qs(urlparse(self.path).query)
+ parsed = urlparse(unquote(self.path))
+ self.path = parsed.path
+ self.query = parse_qs(parsed.query)
+
if self.path.startswith("/api/"):
self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/"))
return
super().do_GET()
def do_POST(self):
- self.path = unquote(self.path)
- self.query = parse_qs(urlparse(self.path).query)
+ parsed = urlparse(unquote(self.path))
+ self.path = parsed.path
+ self.query = parse_qs(parsed.query)
+
if self.path.startswith("/api/"):
self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/"))
return
@@ -95,9 +102,14 @@ class HTTPHandler(SimpleHTTPRequestHandler):
def handle_api_get(self, path: str):
self.log_message(f"API request at {path}")
- if path == "files":
- files: list[str] = self.get_files_meta()
+ if path == "files/to_convert":
+ files: list[str] = self.get_to_convert_files_meta(self.query.get("f", [""])[0])
self.send_json(files)
+
+ elif path == "files/metadata":
+ files: list[str] = self.get_metadata_files_meta()
+ self.send_json(files)
+
elif path.startswith("file"):
filename: str = path.split("/", 1)[1]
data = self.read_file(filename)
@@ -126,49 +138,78 @@ class HTTPHandler(SimpleHTTPRequestHandler):
self.end_headers()
self.wfile.write(json.dumps(data).encode("utf-8"))
- def get_files(self):
- return os.listdir(self.DATA_DIR)
+ def get_to_convert_files(self, base_path: str):
+ root_path: str = os.path.abspath(self.TO_CONVERT_DIR)
+ full_path: str = os.path.join(root_path, base_path)
+ full_path = os.path.abspath(full_path)
+ common_prefix: str = os.path.commonprefix([full_path, root_path])
+
+ if common_prefix != root_path:
+ return []
+
+ return os.listdir(full_path)
+
+ def get_metadata_files(self):
+ return os.listdir(self.METADATA_DIR)
def read_file(self, filename: str) -> Optional[dict|list]:
- if filename not in self.get_files():
+ if filename not in self.get_metadata_files():
return None
- with open(os.path.join(self.DATA_DIR, filename), "r") as f:
+ with open(os.path.join(self.METADATA_DIR, filename), "r") as f:
data = json.load(f)
return data
def write_file(self, filename: str, data: dict|list) -> bool:
- if filename not in self.get_files():
+ if filename not in self.get_metadata_files():
self.send_error(HTTPStatus.NOT_FOUND)
return False
try:
- with open(os.path.join(self.DATA_DIR, filename), "w", encoding="utf-8") as f:
+ with open(os.path.join(self.METADATA_DIR, filename), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except:
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
return False
return True
- def get_files_meta(self):
- files: list[str] = self.get_files()
+ def get_to_convert_files_meta(self, base_path: str):
+ files: list[str] = self.get_to_convert_files(base_path)
+ files = [os.path.join(self.TO_CONVERT_DIR, base_path, f) for f in files]
files_meta: list[dict] = []
- deleted = set(self.CACHE.keys()) - set(files)
+ deleted = set(self.TO_CONVERT_CACHE.keys()) - set(files)
+ for path in deleted:
+ del self.TO_CONVERT_CACHE[path]
+
+ for path in files:
+ last_modified: float = os.path.getmtime(path)
+ if path not in self.TO_CONVERT_CACHE or self.TO_CONVERT_CACHE[path]["ts"] < last_modified:
+ self.update_to_convert_file_meta(path)
+
+ files_meta.append(self.TO_CONVERT_CACHE[path])
+
+ return files_meta
+
+ def get_metadata_files_meta(self):
+ files: list[str] = self.get_metadata_files()
+ files_meta: list[dict] = []
+
+ deleted = set(self.METADATA_CACHE.keys()) - set(files)
for filename in deleted:
- del self.CACHE[deleted]
+ del self.METADATA_CACHE[filename]
for filename in files:
- path: str = os.path.join(self.DATA_DIR, filename)
+ path: str = os.path.join(self.METADATA_DIR, filename)
last_modified: float = os.path.getmtime(path)
- if filename not in self.CACHE or self.CACHE[filename]["ts"] < last_modified:
- self.update_file_meta(filename)
+ if filename not in self.METADATA_CACHE or self.METADATA_CACHE[filename]["ts"] < last_modified:
+ self.update_metadata_file_meta(filename)
- files_meta.append(self.CACHE[filename])
+ files_meta.append(self.METADATA_CACHE[filename])
return files_meta
- def update_file_meta(self, filename: str):
- path: str = os.path.join(self.DATA_DIR, filename)
+ def update_metadata_file_meta(self, filename: str):
+ path: str = os.path.join(self.METADATA_DIR, filename)
meta = {
"filename": filename,
@@ -185,7 +226,25 @@ class HTTPHandler(SimpleHTTPRequestHandler):
else:
meta["title"] = data["title"]
- self.CACHE[filename] = meta
+ self.METADATA_CACHE[filename] = meta
+
+ def update_to_convert_file_meta(self, path: str):
+ filename: str = os.path.basename(path)
+
+ is_dir: bool = os.path.isdir(path)
+ meta = {
+ "path": os.path.relpath(path, self.TO_CONVERT_DIR),
+ "filename": filename,
+ "ts": os.path.getmtime(path),
+ "size": os.path.getsize(path),
+ "type": "folder" if is_dir else "media"
+ }
+ if is_dir:
+ meta["elements"] = len(os.listdir(path))
+ if not meta["path"].endswith("/"):
+ meta["path"] += "/"
+
+ self.TO_CONVERT_CACHE[path] = meta
class MeliesServer(FileSystemEventHandler):