Browse Source

Merge branch 'master' into enhance/graph-parser-part-one

Tienson Qin 3 years ago
parent
commit
3f4ca7c4ba
35 changed files with 2147 additions and 108 deletions
  1. 1 3
      .carve/config.edn
  2. 28 0
      ios/App/App.xcodeproj/project.pbxproj
  3. 235 0
      ios/App/App/FileSync/Extensions.swift
  4. 19 0
      ios/App/App/FileSync/FileSync.m
  5. 290 0
      ios/App/App/FileSync/FileSync.swift
  6. 36 0
      ios/App/App/FileSync/Payload.swift
  7. 326 0
      ios/App/App/FileSync/SyncClient.swift
  8. 8 4
      ios/App/App/FsWatcher.swift
  9. 2 0
      ios/App/Podfile
  10. 6 0
      libs/src/LSPlugin.ts
  11. 1 1
      resources/package.json
  12. 1 1
      src/electron/electron/file_sync_rsapi.cljs
  13. 2 1
      src/main/frontend/components/editor.cljs
  14. 1 1
      src/main/frontend/components/header.cljs
  15. 1 1
      src/main/frontend/config.cljs
  16. 12 8
      src/main/frontend/db/model.cljs
  17. 649 1
      src/main/frontend/dicts.cljc
  18. 1 1
      src/main/frontend/extensions/excalidraw.cljs
  19. 17 14
      src/main/frontend/extensions/latex.cljs
  20. 115 9
      src/main/frontend/fs/sync.cljs
  21. 47 19
      src/main/frontend/handler/editor.cljs
  22. 7 5
      src/main/frontend/handler/events.cljs
  23. 4 2
      src/main/frontend/handler/user.cljs
  24. 17 2
      src/main/frontend/mobile/core.cljs
  25. 4 8
      src/main/frontend/mobile/footer.cljs
  26. 3 0
      src/main/frontend/mobile/intent.cljs
  27. 1 0
      src/main/frontend/mobile/record.cljs
  28. 3 1
      src/main/frontend/mobile/util.cljs
  29. 4 4
      src/main/frontend/modules/shortcut/config.cljs
  30. 2 2
      src/main/frontend/modules/shortcut/core.cljs
  31. 17 2
      src/main/frontend/modules/shortcut/data_helper.cljs
  32. 243 16
      src/main/frontend/modules/shortcut/dicts.cljc
  33. 4 2
      src/test/frontend/modules/outliner/core_test.cljs
  34. 14 0
      templates/dummy-notes-tr.md
  35. 26 0
      templates/tutorial-tr.md

+ 1 - 3
.carve/config.edn

@@ -5,7 +5,5 @@
                   ;; Ignore b/c too many false positives
                   frontend.db
                   ;; Used for debugging
-                  frontend.db.debug
-                  ;; namespace fns are lazily loaded
-                  frontend.extensions.age-encryption]
+                  frontend.db.debug]
  :report {:format :ignore}}

+ 28 - 0
ios/App/App.xcodeproj/project.pbxproj

@@ -25,8 +25,13 @@
 		D32752BE275496C60039291C /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D32752BD275496C60039291C /* CloudKit.framework */; };
 		D3D62A0A275C92880003FBDC /* FileContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3D62A09275C92880003FBDC /* FileContainer.swift */; };
 		D3D62A0C275C928F0003FBDC /* FileContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = D3D62A0B275C928F0003FBDC /* FileContainer.m */; };
+		FE443F1C27FF5420007ECE65 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE443F1B27FF5420007ECE65 /* Extensions.swift */; };
+		FE443F1E27FF54AA007ECE65 /* Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE443F1D27FF54AA007ECE65 /* Payload.swift */; };
+		FE443F2027FF54C9007ECE65 /* SyncClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE443F1F27FF54C9007ECE65 /* SyncClient.swift */; };
 		FE647FF427BDFEDE00F3206B /* FsWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF327BDFEDE00F3206B /* FsWatcher.swift */; };
 		FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF527BDFEF500F3206B /* FsWatcher.m */; };
+		FE8C946B27FD762700C8017B /* FileSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8C946927FD762700C8017B /* FileSync.swift */; };
+		FE8C946C27FD762700C8017B /* FileSync.m in Sources */ = {isa = PBXBuildFile; fileRef = FE8C946A27FD762700C8017B /* FileSync.m */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -81,8 +86,13 @@
 		D3D62A09275C92880003FBDC /* FileContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileContainer.swift; sourceTree = "<group>"; };
 		D3D62A0B275C928F0003FBDC /* FileContainer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FileContainer.m; sourceTree = "<group>"; };
 		DE5650F4AD4E2242AB9C012D /* Pods-Logseq.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Logseq.debug.xcconfig"; path = "Target Support Files/Pods-Logseq/Pods-Logseq.debug.xcconfig"; sourceTree = "<group>"; };
+		FE443F1B27FF5420007ECE65 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
+		FE443F1D27FF54AA007ECE65 /* Payload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Payload.swift; sourceTree = "<group>"; };
+		FE443F1F27FF54C9007ECE65 /* SyncClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncClient.swift; sourceTree = "<group>"; };
 		FE647FF327BDFEDE00F3206B /* FsWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FsWatcher.swift; sourceTree = "<group>"; };
 		FE647FF527BDFEF500F3206B /* FsWatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FsWatcher.m; sourceTree = "<group>"; };
+		FE8C946927FD762700C8017B /* FileSync.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileSync.swift; sourceTree = "<group>"; };
+		FE8C946A27FD762700C8017B /* FileSync.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FileSync.m; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -142,6 +152,7 @@
 				50B271D01FEDC1A000F3C39B /* public */,
 				7435D10B2704659F00AB88E0 /* FolderPicker.swift */,
 				FE647FF327BDFEDE00F3206B /* FsWatcher.swift */,
+				FE443F1A27FF53A2007ECE65 /* FileSync */,
 				FE647FF527BDFEF500F3206B /* FsWatcher.m */,
 				7435D10E2704660B00AB88E0 /* FolderPicker.m */,
 				D3D62A09275C92880003FBDC /* FileContainer.swift */,
@@ -180,6 +191,18 @@
 			path = Pods;
 			sourceTree = "<group>";
 		};
+		FE443F1A27FF53A2007ECE65 /* FileSync */ = {
+			isa = PBXGroup;
+			children = (
+				FE8C946927FD762700C8017B /* FileSync.swift */,
+				FE443F1F27FF54C9007ECE65 /* SyncClient.swift */,
+				FE443F1D27FF54AA007ECE65 /* Payload.swift */,
+				FE443F1B27FF5420007ECE65 /* Extensions.swift */,
+				FE8C946A27FD762700C8017B /* FileSync.m */,
+			);
+			path = FileSync;
+			sourceTree = "<group>";
+		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
@@ -331,13 +354,18 @@
 			files = (
 				504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
 				5FD5BB71278579F5008E6875 /* DownloadiCloudFiles.swift in Sources */,
+				FE443F1E27FF54AA007ECE65 /* Payload.swift in Sources */,
+				FE8C946B27FD762700C8017B /* FileSync.swift in Sources */,
 				FE647FF427BDFEDE00F3206B /* FsWatcher.swift in Sources */,
 				5FD5BB73278579FF008E6875 /* DownloadiCloudFiles.m in Sources */,
 				D3D62A0A275C92880003FBDC /* FileContainer.swift in Sources */,
 				D3D62A0C275C928F0003FBDC /* FileContainer.m in Sources */,
+				FE443F1C27FF5420007ECE65 /* Extensions.swift in Sources */,
+				FE8C946C27FD762700C8017B /* FileSync.m in Sources */,
 				7435D10F2704660B00AB88E0 /* FolderPicker.m in Sources */,
 				7435D10C2704659F00AB88E0 /* FolderPicker.swift in Sources */,
 				FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */,
+				FE443F2027FF54C9007ECE65 /* SyncClient.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 235 - 0
ios/App/App/FileSync/Extensions.swift

@@ -0,0 +1,235 @@
+//
+//  Extensions.swift
+//  Logseq
+//
+//  Created by Mono Wang on 4/8/R4.
+//
+
+import Foundation
+import CryptoKit
+
+
+import var CommonCrypto.CC_MD5_DIGEST_LENGTH
+import func CommonCrypto.CC_MD5
+import typealias CommonCrypto.CC_LONG
+
+// via https://github.com/krzyzanowskim/CryptoSwift
+extension Array where Element == UInt8 {
+  public init(hex: String) {
+      self = Array.init()
+      self.reserveCapacity(hex.unicodeScalars.lazy.underestimatedCount)
+      var buffer: UInt8?
+      var skip = hex.hasPrefix("0x") ? 2 : 0
+      for char in hex.unicodeScalars.lazy {
+          guard skip == 0 else {
+              skip -= 1
+              continue
+          }
+          guard char.value >= 48 && char.value <= 102 else {
+              removeAll()
+              return
+          }
+          let v: UInt8
+          let c: UInt8 = UInt8(char.value)
+          switch c {
+          case let c where c <= 57:
+              v = c - 48
+          case let c where c >= 65 && c <= 70:
+              v = c - 55
+          case let c where c >= 97:
+              v = c - 87
+          default:
+              removeAll()
+              return
+          }
+          if let b = buffer {
+              append(b << 4 | v)
+              buffer = nil
+          } else {
+              buffer = v
+          }
+      }
+      if let b = buffer {
+          append(b)
+      }
+  }
+}
+
+@available(iOS 13.0, *)
+extension SymmetricKey {
+    public init(passwordString keyString: String) throws {
+        let size = SymmetricKeySize.bits256
+        guard var keyData = keyString.data(using: .utf8) else {
+            print("Could not create raw Data from String.")
+            throw CryptoKitError.incorrectParameterSize
+        }
+            
+        let keySizeBytes = size.bitCount / 8
+        keyData = keyData.subdata(in: 0..<keySizeBytes)
+        guard keyData.count >= keySizeBytes else { throw CryptoKitError.incorrectKeySize }
+        
+        print("debug key \(keyData) \(keyData.hexDescription)")
+        
+        self.init(data: keyData)
+    }
+}
+
+extension Data {
+    public init?(hexEncoded: String) {
+        self.init(Array<UInt8>(hex: hexEncoded))
+    }
+    
+    var hexDescription: String {
+        return map { String(format: "%02hhx", $0) }.joined()
+    }
+    
+    @available(iOS 13.0, *)
+    func aesEncrypt(keyString: String) throws -> Data {
+        let key = try? SymmetricKey(passwordString: keyString)
+        
+        let nonce = Data(hexEncoded: "131348c0987c7eece60fc0bc") // = initialization vector
+        let tag = Data(hexEncoded: "5baa85ff3e7eda3204744ec74b71d523")
+        
+        print("debug tag \(tag?.hexDescription) nonce \(nonce?.hexDescription)")
+        let sealedData = try! AES.GCM.seal(self, using: key!, nonce: AES.GCM.Nonce(data: nonce!), authenticating: tag!)
+            
+        print("debug encrypted \(sealedData)")
+        guard let encryptedContent = sealedData.combined else {
+            throw CryptoKitError.underlyingCoreCryptoError(error: 2)
+        }
+        print("debug encrypted \(encryptedContent)")
+        print("debug encrypted \(encryptedContent.hexDescription)")
+        print("debug tag \(sealedData.tag.hexDescription)")
+        return encryptedContent
+    }
+    
+    @available(iOS 13.0, *)
+    func aesDecrypt(keyString: String) throws -> Data {
+        let key = try! SymmetricKey(passwordString: keyString)
+        let tag = Data(hexEncoded: "5baa85ff3e7eda3204744ec74b71d523")
+        
+        guard let sealedBox = try? AES.GCM.SealedBox(combined: self) else {
+            throw CryptoKitError.authenticationFailure
+        }
+        guard let decryptedData = try? AES.GCM.open(sealedBox, using: key, authenticating: tag!) else {
+            throw CryptoKitError.authenticationFailure
+        }
+        return decryptedData
+    }
+}
+
+extension String {
+    var MD5: String {
+        // TODO: incremental hash
+        if #available(iOS 13.0, *) {
+            let computed = Insecure.MD5.hash(data: self.data(using: .utf8)!)
+            return computed.map { String(format: "%02hhx", $0) }.joined()
+        } else {
+            // Fallback on earlier versions, no CryptoKit
+            let length = Int(CC_MD5_DIGEST_LENGTH)
+            let messageData = self.data(using:.utf8)!
+            var digestData = Data(count: length)
+            
+            _ = digestData.withUnsafeMutableBytes { digestBytes -> UInt8 in
+                messageData.withUnsafeBytes { messageBytes -> UInt8 in
+                    if let messageBytesBaseAddress = messageBytes.baseAddress, let digestBytesBlindMemory = digestBytes.bindMemory(to: UInt8.self).baseAddress {
+                        let messageLength = CC_LONG(messageData.count)
+                        CC_MD5(messageBytesBaseAddress, messageLength, digestBytesBlindMemory)
+                    }
+                    return 0
+                }
+            }
+            return digestData.map { String(format: "%02hhx", $0) }.joined()
+        }
+    }
+    
+    func encodeAsFname() -> String {
+        var allowed = NSMutableCharacterSet.urlPathAllowed
+        allowed.remove(charactersIn: "&$@=;:+ ,?%#")
+        return self.addingPercentEncoding(withAllowedCharacters: allowed) ?? self
+    }
+    
+    func decodeFromFname() -> String {
+        return self.removingPercentEncoding ?? self
+    }
+    
+    static func random(length: Int) -> String {
+        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+        return String((0..<length).map{ _ in letters.randomElement()! })
+    }
+}
+
+extension URL {
+    func relativePath(from base: URL) -> String? {
+        // Ensure that both URLs represent files:
+        guard self.isFileURL && base.isFileURL else {
+            return nil
+        }
+        
+        // Remove/replace "." and "..", make paths absolute:
+        let destComponents = self.standardized.pathComponents
+        let baseComponents = base.standardized.pathComponents
+        
+        // Find number of common path components:
+        var i = 0
+        while i < destComponents.count && i < baseComponents.count
+                && destComponents[i] == baseComponents[i] {
+            i += 1
+        }
+        
+        // Build relative path:
+        var relComponents = Array(repeating: "..", count: baseComponents.count - i)
+        relComponents.append(contentsOf: destComponents[i...])
+        return relComponents.joined(separator: "/")
+    }
+    
+    func download(toFile file: URL, completion: @escaping (Error?) -> Void) {
+        // Download the remote URL to a file
+        let task = URLSession.shared.downloadTask(with: self) {
+            (tempURL, response, error) in
+            // Early exit on error
+            guard let tempURL = tempURL else {
+                completion(error)
+                return
+            }
+            
+            if let response = response! as? HTTPURLResponse {
+                if response.statusCode == 404 {
+                    completion(NSError(domain: "",
+                                       code: response.statusCode,
+                                       userInfo: [NSLocalizedDescriptionKey: "remote file not found"]))
+                    return
+                }
+                if response.statusCode != 200 {
+                    completion(NSError(domain: "",
+                                       code: response.statusCode,
+                                       userInfo: [NSLocalizedDescriptionKey: "invalid http status code"]))
+                    return
+                }
+            }
+            
+            do {
+                // Remove any existing document at file
+                if FileManager.default.fileExists(atPath: file.path) {
+                    try FileManager.default.removeItem(at: file)
+                }
+                
+                // Copy the tempURL to file
+                try FileManager.default.copyItem(
+                    at: tempURL,
+                    to: file
+                )
+                
+                completion(nil)
+            }
+            
+            // Handle potential file system errors
+            catch {
+                completion(error)
+            }
+        }
+        
+        // Start the download
+        task.resume()
+    }
+}

+ 19 - 0
ios/App/App/FileSync/FileSync.m

@@ -0,0 +1,19 @@
+//
+//  FileSync.m
+//  Logseq
+//
+//  Created by Mono Wang on 2/24/R4.
+//
+
+#import <Capacitor/Capacitor.h>
+
+CAP_PLUGIN(FileSync, "FileSync",
+           CAP_PLUGIN_METHOD(setEnv, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(getLocalFilesMeta, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(getLocalAllFilesMeta, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(renameLocalFile, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(deleteLocalFiles, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(updateLocalFiles, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(deleteRemoteFiles, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(updateRemoteFiles, CAPPluginReturnPromise);
+)

+ 290 - 0
ios/App/App/FileSync/FileSync.swift

@@ -0,0 +1,290 @@
+//
+//  FileSync.swift
+//  Logseq
+//
+//  Created by Mono Wang on 2/24/R4.
+//
+
+import Capacitor
+import Foundation
+import AWSMobileClient
+import CryptoKit
+
+// MARK: Global variables
+
+// Defualts to dev
+var URL_BASE = URL(string: "https://api.logseq.com/file-sync/")!
+var BUCKET: String = "logseq-file-sync-bucket"
+var REGION: String = "us-east-2"
+
+// MARK: FileSync Plugin
+
+@objc(FileSync)
+public class FileSync: CAPPlugin, SyncDebugDelegate {
+    override public func load() {
+        print("debug File sync iOS plugin loaded!")
+        AWSMobileClient.default().initialize { (userState, error) in
+            guard error == nil else {
+                print("error initializing AWSMobileClient. Error: \(error!.localizedDescription)")
+                return
+            }
+        }
+    }
+    
+    // NOTE: for debug, or an activity indicator
+    public func debugNotification(_ message: [String: Any]) {
+        self.notifyListeners("debug", data: message)
+    }
+    
+    @objc func setEnv(_ call: CAPPluginCall) {
+        guard let env = call.getString("env") else {
+            call.reject("required parameter: env")
+            return
+        }
+        switch env {
+        case "production", "product", "prod":
+            URL_BASE = URL(string: "https://api-prod.logseq.com/file-sync/")!
+            BUCKET = "logseq-file-sync-bucket-prod"
+            REGION = "us-east-1"
+        case "development", "develop", "dev":
+            URL_BASE = URL(string: "https://api.logseq.com/file-sync/")!
+            BUCKET = "logseq-file-sync-bucket"
+            REGION = "us-east-2"
+        default:
+            call.reject("invalid env: \(env)")
+            return
+        }
+        self.debugNotification(["event": "setenv:\(env)"])
+        call.resolve(["ok": true])
+    }
+    
+    @objc func getLocalFilesMeta(_ call: CAPPluginCall) {
+        guard let basePath = call.getString("basePath"),
+              let filePaths = call.getArray("filePaths") as? [String] else {
+                  call.reject("required paremeters: basePath, filePaths")
+                  return
+              }
+        guard let baseURL = URL(string: basePath) else {
+            call.reject("invalid basePath")
+            return
+        }
+        
+        var fileMd5Digests: [String: [String: Any]] = [:]
+        for filePath in filePaths {
+            let url = baseURL.appendingPathComponent(filePath)
+            if let content = try? String(contentsOf: url, encoding: .utf8) {
+                fileMd5Digests[filePath] = ["md5": content.MD5,
+                                            "size": content.lengthOfBytes(using: .utf8)]
+            }
+        }
+        
+        call.resolve(["result": fileMd5Digests])
+    }
+    
+    @objc func getLocalAllFilesMeta(_ call: CAPPluginCall) {
+        guard let basePath = call.getString("basePath"),
+              let baseURL = URL(string: basePath) else {
+                  call.reject("invalid basePath")
+                  return
+              }
+        
+        var fileMd5Digests: [String: [String: Any]] = [:]
+        if let enumerator = FileManager.default.enumerator(at: baseURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsPackageDescendants, .skipsHiddenFiles]) {
+            
+            for case let fileURL as URL in enumerator {
+                if !fileURL.isSkipped() {
+                    if let content = try? String(contentsOf: fileURL, encoding: .utf8) {
+                        fileMd5Digests[fileURL.relativePath(from: baseURL)!] = ["md5": content.MD5,
+                                                                                "size": content.lengthOfBytes(using: .utf8)]
+                    }
+                } else if fileURL.isICloudPlaceholder() {
+                    try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
+                }
+            }
+        }
+        call.resolve(["result": fileMd5Digests])
+    }
+    
+    
+    @objc func renameLocalFile(_ call: CAPPluginCall) {
+        guard let basePath = call.getString("basePath"),
+              let baseURL = URL(string: basePath) else {
+                  call.reject("invalid basePath")
+                  return
+              }
+        guard let from = call.getString("from") else {
+            call.reject("invalid from file")
+            return
+        }
+        guard let to = call.getString("to") else {
+            call.reject("invalid to file")
+            return
+        }
+        
+        let fromUrl = baseURL.appendingPathComponent(from)
+        let toUrl = baseURL.appendingPathComponent(to)
+        
+        do {
+            try FileManager.default.moveItem(at: fromUrl, to: toUrl)
+        } catch {
+            call.reject("can not rename file: \(error.localizedDescription)")
+            return
+        }
+        call.resolve(["ok": true])
+        
+    }
+    
+    @objc func deleteLocalFiles(_ call: CAPPluginCall) {
+        guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
+              let filePaths = call.getArray("filePaths") as? [String] else {
+                  call.reject("required paremeters: basePath, filePaths")
+                  return
+              }
+        
+        for filePath in filePaths {
+            let fileUrl = baseURL.appendingPathComponent(filePath)
+            try? FileManager.default.removeItem(at: fileUrl) // ignore any delete errors
+        }
+        call.resolve(["ok": true])
+    }
+    
+    /// remote -> local
+    @objc func updateLocalFiles(_ call: CAPPluginCall) {
+        guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
+              let filePaths = call.getArray("filePaths") as? [String],
+              let graphUUID = call.getString("graphUUID") ,
+              let token = call.getString("token") else {
+                  call.reject("required paremeters: basePath, filePaths, graphUUID, token")
+                  return
+              }
+        
+        let client = SyncClient(token: token, graphUUID: graphUUID)
+        client.delegate = self // receives notification
+        
+        client.getFiles(at: filePaths) {  (fileURLs, error) in
+            if let error = error {
+                print("debug getFiles error \(error)")
+                self.debugNotification(["event": "download:error", "data": ["message": "error while getting files \(filePaths)"]])
+                call.reject(error.localizedDescription)
+            } else {
+                // handle multiple completionHandlers
+                let group = DispatchGroup()
+                
+                var downloaded: [String] = []
+                
+                for (filePath, remoteFileURL) in fileURLs {
+                    group.enter()
+
+                    // NOTE: fileURLs from getFiles API is percent-encoded
+                    let localFileURL = baseURL.appendingPathComponent(filePath.decodeFromFname())
+                    remoteFileURL.download(toFile: localFileURL) {error in
+                        if let error = error {
+                            self.debugNotification(["event": "download:error", "data": ["message": "error while downloading \(filePath): \(error)"]])
+                            print("debug download \(error) in \(filePath)")
+                        } else {
+                            self.debugNotification(["event": "download:file", "data": ["file": filePath]])
+                            downloaded.append(filePath)
+                        }
+                        group.leave()
+                    }
+                }
+                group.notify(queue: .main) {
+                    self.debugNotification(["event": "download:done"])
+                    call.resolve(["ok": true, "data": downloaded])
+                }
+                
+            }
+        }
+    }
+    
+    @objc func deleteRemoteFiles(_ call: CAPPluginCall) {
+        guard let filePaths = call.getArray("filePaths") as? [String],
+              let graphUUID = call.getString("graphUUID"),
+              let token = call.getString("token"),
+              let txid = call.getInt("txid") else {
+                  call.reject("required paremeters: filePaths, graphUUID, token, txid")
+                  return
+              }
+        guard !filePaths.isEmpty else {
+            call.reject("empty filePaths")
+            return
+        }
+        
+        let client = SyncClient(token: token, graphUUID: graphUUID, txid: txid)
+        client.deleteFiles(filePaths) { txid, error in
+            guard error == nil else {
+                call.reject("delete \(error!)")
+                return
+            }
+            guard let txid = txid else {
+                call.reject("missing txid")
+                return
+            }
+            call.resolve(["ok": true, "txid": txid])
+        }
+    }
+    
+    /// local -> remote
+    @objc func updateRemoteFiles(_ call: CAPPluginCall) {
+        guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
+              let filePaths = call.getArray("filePaths") as? [String],
+              let graphUUID = call.getString("graphUUID"),
+              let token = call.getString("token"),
+              let txid = call.getInt("txid") else {
+                  call.reject("required paremeters: basePath, filePaths, graphUUID, token, txid")
+                  return
+              }
+        guard !filePaths.isEmpty else {
+            return call.reject("empty filePaths")
+        }
+        
+        print("debug begin updateRemoteFiles \(filePaths)")
+        
+        let client = SyncClient(token: token, graphUUID: graphUUID, txid: txid)
+        client.delegate = self
+        
+        // 1. refresh_temp_credential
+        client.getTempCredential() { (credentials, error) in
+            guard error == nil else {
+                self.debugNotification(["event": "upload:error", "data": ["message": "error while refreshing credential: \(error!)"]])
+                call.reject("error(getTempCredential): \(error!)")
+                return
+            }
+            
+            var files: [String: URL] = [:]
+            for filePath in filePaths {
+                // NOTE: filePath from js may contain spaces
+                let fileURL = baseURL.appendingPathComponent(filePath)
+                files[filePath.encodeAsFname()] = fileURL
+            }
+            
+            // 2. upload_temp_file
+            client.uploadTempFiles(files, credentials: credentials!) { (uploadedFileKeyDict, error) in
+                guard error == nil else {
+                    self.debugNotification(["event": "upload:error", "data": ["message": "error while uploading temp files: \(error!)"]])
+                    call.reject("error(uploadTempFiles): \(error!)")
+                    return
+                }
+                // 3. update_files
+                guard !uploadedFileKeyDict.isEmpty else {
+                    self.debugNotification(["event": "upload:error", "data": ["message": "no file to update"]])
+                    call.reject("no file to update")
+                    return
+                }
+                client.updateFiles(uploadedFileKeyDict) { (txid, error) in
+                    guard error == nil else {
+                        self.debugNotification(["event": "upload:error", "data": ["message": "error while updating files: \(error!)"]])
+                        call.reject("error updateFiles: \(error!)")
+                        return
+                    }
+                    guard let txid = txid else {
+                        call.reject("error: missing txid")
+                        return
+                    }
+                    self.debugNotification(["event": "upload:done", "data": ["files": filePaths, "txid": txid]])
+                    call.resolve(["ok": true, "files": uploadedFileKeyDict, "txid": txid])
+                }
+            }
+        }
+    }
+}

+ 36 - 0
ios/App/App/FileSync/Payload.swift

@@ -0,0 +1,36 @@
+//
+//  Payload.swift
+//  Logseq
+//
+//  Created by Mono Wang on 4/8/R4.
+//
+
+import Foundation
+
+struct GetFilesResponse: Decodable {
+    let PresignedFileUrls: [String: String]
+}
+
+struct DeleteFilesResponse: Decodable {
+    let TXId: Int
+    let DeleteSuccFiles: [String]
+    let DeleteFailedFiles: [String: String]
+}
+
+public struct S3Credential: Decodable {
+    let AccessKeyId: String
+    let Expiration: String
+    let SecretKey: String
+    let SessionToken: String
+}
+
+struct GetTempCredentialResponse: Decodable {
+    let Credentials: S3Credential
+    let S3Prefix: String
+}
+
+struct UpdateFilesResponse: Decodable {
+    let TXId: Int
+    let UpdateSuccFiles: [String]
+    let UpdateFailedFiles: [String: String]
+}

+ 326 - 0
ios/App/App/FileSync/SyncClient.swift

@@ -0,0 +1,326 @@
+//
+//  SyncClient.swift
+//  Logseq
+//
+//  Created by Mono Wang on 4/8/R4.
+//
+
+import Foundation
+import AWSMobileClient
+import AWSS3
+
+public protocol SyncDebugDelegate {
+    func debugNotification(_ message: [String: Any])
+}
+
+
+public class SyncClient {
+    private var token: String
+    private var graphUUID: String?
+    private var txid: Int = 0
+    private var s3prefix: String?
+    
+    public var delegate: SyncDebugDelegate? = nil
+    
+    public init(token: String) {
+        self.token = token
+    }
+    
+    public init(token: String, graphUUID: String) {
+        self.token = token
+        self.graphUUID = graphUUID
+    }
+    
+    public init(token: String, graphUUID: String, txid: Int) {
+        self.token = token
+        self.graphUUID = graphUUID
+        self.txid = txid
+    }
+
+    // get_files
+    // => file_path, file_url
+    public func getFiles(at filePaths: [String], completionHandler: @escaping ([String: URL], Error?) -> Void) {
+        let url = URL_BASE.appendingPathComponent("get_files")
+        
+        var request = URLRequest(url: url)
+        request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
+        request.setValue("Logseq-sync/0.1", forHTTPHeaderField: "User-Agent")
+        request.setValue("Bearer \(self.token)", forHTTPHeaderField: "Authorization")
+        
+        let payload = [
+            "GraphUUID": self.graphUUID ?? "",
+            "Files": filePaths.map { filePath in filePath.encodeAsFname()}
+        ] as [String : Any]
+        let bodyData = try? JSONSerialization.data(
+            withJSONObject: payload,
+            options: []
+        )
+        request.httpMethod = "POST"
+        request.httpBody = bodyData
+        
+        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
+            guard error == nil else {
+                completionHandler([:], error)
+                return
+            }
+            
+            if (response as? HTTPURLResponse)?.statusCode != 200 {
+                let body = String(data: data!, encoding: .utf8) ?? "";
+                completionHandler([:], NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: "http error \(body)"]))
+                return
+            }
+            
+            if let data = data {
+                let resp = try? JSONDecoder().decode([String:[String:String]].self, from: data)
+                let files = resp?["PresignedFileUrls"] ?? [:]
+                self.delegate?.debugNotification(["event": "download:prepare"])
+                completionHandler(files.mapValues({ url in URL(string: url)!}), nil)
+            } else {
+                // Handle unexpected error
+                completionHandler([:], NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: "unexpected error"]))
+            }
+        }
+        task.resume()
+    }
+    
+    
+    public func deleteFiles(_ filePaths: [String], completionHandler: @escaping  (Int?, Error?) -> Void) {
+        let url = URL_BASE.appendingPathComponent("delete_files")
+        
+        var request = URLRequest(url: url)
+        request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
+        request.setValue("Logseq-sync/0.1", forHTTPHeaderField: "User-Agent")
+        request.setValue("Bearer \(self.token)", forHTTPHeaderField: "Authorization")
+        
+        let payload = [
+            "GraphUUID": self.graphUUID ?? "",
+            "Files": filePaths.map { filePath in filePath.encodeAsFname()},
+            "TXId": self.txid,
+        ] as [String : Any]
+        let bodyData = try? JSONSerialization.data(
+            withJSONObject: payload,
+            options: []
+        )
+        request.httpMethod = "POST"
+        request.httpBody = bodyData
+        
+        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
+            guard error == nil else {
+                completionHandler(nil, error)
+                return
+            }
+            
+            if let response = response as? HTTPURLResponse {
+                let body = String(data: data!, encoding: .utf8) ?? ""
+                
+                if response.statusCode == 409 {
+                    if body.contains("txid_to_validate") {
+                        completionHandler(nil, NSError(domain: "",
+                                                       code: 409,
+                                                       userInfo: [NSLocalizedDescriptionKey: "invalid txid: \(body)"]))
+                        return
+                    }
+                    // fallthrough
+                }
+                if response.statusCode != 200 {
+                    completionHandler(nil, NSError(domain: "",
+                                                   code: response.statusCode,
+                                                   userInfo: [NSLocalizedDescriptionKey: "invalid http status \(response.statusCode): \(body)"]))
+                    return
+                }
+            }
+            
+            if let data = data {
+                do {
+                    let resp = try JSONDecoder().decode(DeleteFilesResponse.self, from: data)
+                    // TODO: handle api resp?
+                    self.delegate?.debugNotification(["event": "delete"])
+                    completionHandler(resp.TXId, nil)
+                } catch {
+                    completionHandler(nil, error)
+                }
+            } else {
+                // Handle unexpected error
+                completionHandler(nil, NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: "unexpected error"]))
+            }
+        }
+        task.resume()
+    }
+    
+    // (txid, error)
+    public func updateFiles(_ fileKeyDict: [String: String], completionHandler: @escaping  (Int?, Error?) -> Void) {
+        let url = URL_BASE.appendingPathComponent("update_files")
+        
+        var request = URLRequest(url: url)
+        request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
+        request.setValue("Logseq-sync/0.1", forHTTPHeaderField: "User-Agent")
+        request.setValue("Bearer \(self.token)", forHTTPHeaderField: "Authorization")
+        
+        let payload = [
+            "GraphUUID": self.graphUUID ?? "",
+            "Files": Dictionary(uniqueKeysWithValues: fileKeyDict.map { ($0, $1) }) as [String: String] as Any,
+            "TXId": self.txid,
+        ] as [String : Any]
+        let bodyData = try? JSONSerialization.data(
+            withJSONObject: payload,
+            options: []
+        )
+        request.httpMethod = "POST"
+        request.httpBody = bodyData
+        
+        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
+            guard error == nil else {
+                completionHandler(nil, error)
+                return
+            }
+            
+            if let response = response as? HTTPURLResponse {
+                let body = String(data: data!, encoding: .utf8) ?? ""
+                
+                if response.statusCode == 409 {
+                    if body.contains("txid_to_validate") {
+                        completionHandler(nil, NSError(domain: "",
+                                                       code: 409,
+                                                       userInfo: [NSLocalizedDescriptionKey: "invalid txid: \(body)"]))
+                        return
+                    }
+                    // fallthrough
+                }
+                if response.statusCode != 200 {
+                    completionHandler(nil, NSError(domain: "",
+                                                   code: response.statusCode,
+                                                   userInfo: [NSLocalizedDescriptionKey: "invalid http status \(response.statusCode): \(body)"]))
+                    return
+                }
+            }
+            
+            if let data = data {
+                let resp = try? JSONDecoder().decode(UpdateFilesResponse.self, from: data)
+                if resp?.UpdateFailedFiles.isEmpty ?? true {
+                    completionHandler(resp?.TXId, nil)
+                } else {
+                    completionHandler(nil, NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: "update fail for some files: \(resp?.UpdateFailedFiles.debugDescription)"]))
+                }
+            } else {
+                // Handle unexpected error
+                completionHandler(nil, NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: "unexpected error"]))
+            }
+        }
+        task.resume()
+    }
+    
+    public func getTempCredential(completionHandler: @escaping (S3Credential?, Error?) -> Void) {
+        let url = URL_BASE.appendingPathComponent("get_temp_credential")
+        
+        var request = URLRequest(url: url)
+        request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
+        request.setValue("Logseq-sync/0.1", forHTTPHeaderField: "User-Agent")
+        request.setValue("Bearer \(self.token)", forHTTPHeaderField: "Authorization")
+        request.httpMethod = "POST"
+        request.httpBody = Data()
+        
+        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
+            guard error == nil else {
+                completionHandler(nil, error)
+                return
+            }
+            if let response = response as? HTTPURLResponse {
+                let body = String(data: data!, encoding: .utf8) ?? ""
+                if response.statusCode == 401 {
+                    completionHandler(nil, NSError(domain: "", code: 401, userInfo: [NSLocalizedDescriptionKey: "unauthorized"]))
+                    return
+                }
+                if response.statusCode != 200 {
+                    completionHandler(nil, NSError(domain: "",
+                                                   code: response.statusCode,
+                                                   userInfo: [NSLocalizedDescriptionKey: "invalid http status \(response.statusCode): \(body)"]))
+                    return
+                }
+            }
+            if let data = data {
+                let resp = try? JSONDecoder().decode(GetTempCredentialResponse.self, from: data)
+                // NOTE: remove BUCKET prefix here.
+                self.s3prefix = resp?.S3Prefix.replacingOccurrences(of: "\(BUCKET)/", with: "")
+                self.delegate?.debugNotification(["event": "upload:prepare"])
+                completionHandler(resp?.Credentials, nil)
+            } else {
+                // Handle unexpected error
+                completionHandler(nil, NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: "unexpected error"]))
+            }
+        }
+        task.resume()
+    }
+    
+    // [filePath, Key]
+    public func uploadTempFiles(_ files: [String: URL], credentials: S3Credential, completionHandler: @escaping ([String: String], Error?) -> Void) {
+        let credentialsProvider = AWSBasicSessionCredentialsProvider(
+            accessKey: credentials.AccessKeyId, secretKey: credentials.SecretKey, sessionToken: credentials.SessionToken)
+        let configuration = AWSServiceConfiguration(region: .USEast2, credentialsProvider: credentialsProvider)
+        configuration?.timeoutIntervalForRequest = 5.0
+        configuration?.timeoutIntervalForResource = 5.0
+        
+        let tuConf = AWSS3TransferUtilityConfiguration()
+        tuConf.bucket = BUCKET
+        //x tuConf.isAccelerateModeEnabled = true
+        
+        let transferKey = String.random(length: 10)
+        AWSS3TransferUtility.register(
+            with: configuration!,
+            transferUtilityConfiguration: tuConf,
+            forKey: transferKey
+        ) { (error) in
+            if let error = error {
+                print("error while register tu \(error)")
+            }
+        }
+        
+        let transferUtility = AWSS3TransferUtility.s3TransferUtility(forKey: transferKey)
+        let uploadExpression = AWSS3TransferUtilityUploadExpression()
+        
+        let group = DispatchGroup()
+        var keyFileDict: [String: String] = [:]
+        var fileKeyDict: [String: String] = [:]
+        
+        let uploadCompletionHandler = { (task: AWSS3TransferUtilityUploadTask, error: Error?) -> Void in
+            // ignore any errors in first level of handler
+            if let error = error {
+                self.delegate?.debugNotification(["event": "upload:error", "data": ["key": task.key, "error": error.localizedDescription]])
+            }
+            if let HTTPResponse = task.response {
+                if HTTPResponse.statusCode != 200 || task.status != .completed {
+                    print("debug uploading error \(HTTPResponse)")
+                }
+            }
+            
+            // only save successful keys
+            let filePath = keyFileDict[task.key]!
+            fileKeyDict[filePath] = task.key
+            keyFileDict.removeValue(forKey: task.key)
+            self.delegate?.debugNotification(["event": "upload:file", "data": ["file": filePath, "key": task.key]])
+            group.leave() // notify finish upload
+        }
+        
+        for (filePath, fileLocalURL) in files {
+            print("debug, upload temp \(fileLocalURL) \(filePath)")
+            guard let rawData = try? Data(contentsOf: fileLocalURL) else { continue }
+            group.enter()
+            
+            let randFileName = String.random(length: 15).appending(".").appending(fileLocalURL.pathExtension)
+            let key = "\(self.s3prefix!)/ios\(randFileName)"
+
+            keyFileDict[key] = filePath
+            transferUtility?.uploadData(rawData, key: key, contentType: "application/octet-stream", expression: uploadExpression, completionHandler: uploadCompletionHandler)
+                .continueWith(block: { (task) in
+                    if let error = task.error {
+                        completionHandler([:], error)
+                    }
+                    return nil
+                })
+        }
+        
+        group.notify(queue: .main) {
+            AWSS3TransferUtility.remove(forKey: transferKey)
+            completionHandler(fileKeyDict, nil)
+        }
+    }
+}

+ 8 - 4
ios/App/App/FsWatcher.swift

@@ -84,6 +84,10 @@ extension URL {
         if self.lastPathComponent.starts(with: ".") {
             return true
         }
+        // NOTE: used by file-sync
+        if self.lastPathComponent == "graphs-txid.edn" {
+            return true
+        }
         let allowedPathExtensions: Set = ["md", "markdown", "org", "css", "edn", "excalidraw"]
         if allowedPathExtensions.contains(self.pathExtension.lowercased()) {
             return false
@@ -167,7 +171,7 @@ public class PollingWatcher {
     }
     
     private func tick() {
-        let startTime = DispatchTime.now()
+        // let startTime = DispatchTime.now()
         
         if let enumerator = FileManager.default.enumerator(
             at: url,
@@ -205,9 +209,9 @@ public class PollingWatcher {
             self.updateMetaDb(with: newMetaDb)
         }
         
-        let elapsedNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
-        let elapsedInMs = Double(elapsedNanoseconds) / 1_000_000
-        print("debug ticker elapsed=\(elapsedInMs)ms")
+        // let elapsedNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
+        // let elapsedInMs = Double(elapsedNanoseconds) / 1_000_000
+        // print("debug ticker elapsed=\(elapsedInMs)ms")
         
         if #available(iOS 13.0, *) {
             timer?.schedule(deadline: .now().advanced(by: .seconds(2)), leeway: .milliseconds(100))

+ 2 - 0
ios/App/Podfile

@@ -23,4 +23,6 @@ end
 target 'Logseq' do
   capacitor_pods
   # Add your Pods here
+  pod 'AWSMobileClient'
+  pod 'AWSS3'
 end

+ 6 - 0
libs/src/LSPlugin.ts

@@ -308,6 +308,12 @@ export interface IAppProxy {
     action: SimpleCommandCallback
   ) => void
 
+  /**
+   * Supported key names
+   * @link https://gist.github.com/xyhp915/d1a6d151a99f31647a95e59cdfbf4ddc
+   * @param keybinding
+   * @param action
+   */
   registerCommandShortcut: (
     keybinding: SimpleCommandKeybinding,
     action: SimpleCommandCallback

+ 1 - 1
resources/package.json

@@ -36,7 +36,7 @@
     "https-proxy-agent": "5.0.0",
     "@sentry/electron": "2.5.1",
     "posthog-js": "1.10.2",
-    "@andelf/rsapi": "0.0.7",
+    "@logseq/rsapi": "0.0.9",
     "electron-deeplink": "1.0.9"
   },
   "devDependencies": {

+ 1 - 1
src/electron/electron/file_sync_rsapi.cljs

@@ -1,5 +1,5 @@
 (ns electron.file-sync-rsapi
-  (:require ["@andelf/rsapi" :as rsapi]))
+  (:require ["@logseq/rsapi" :as rsapi]))
 
 (defn set-env [env] (rsapi/setEnv env))
 

+ 2 - 1
src/main/frontend/components/editor.cljs

@@ -409,7 +409,7 @@
               (when set-default-width?
                 {:width max-width})
               (let [^js/HTMLElement textarea
-                    (js/document.querySelector "textarea")]
+                    (js/document.querySelector "textarea.ls-textarea")]
                 (if (<= (.-clientWidth textarea) (+ left (if set-default-width? max-width 500)))
                   {:right 0}
                   {:left (if (and y-diff (= y-diff 0)) left 0)})))}
@@ -477,6 +477,7 @@
   (let [content (if content (str content) "")]
     ;; as the function is binding to the editor content, optimization is welcome
     (str
+     "ls-textarea "
      (if (or (> (.-length content) 1000)
              (string/includes? content "\n"))
        "multiline-block"

+ 1 - 1
src/main/frontend/components/header.cljs

@@ -41,7 +41,7 @@
         (ui/dropdown-with-links
          (fn [{:keys [toggle-fn]}]
            [:a.button
-              {:on-click toggle-fn}
+            {:on-click toggle-fn}
             [:span.text-sm.font-medium (user-handler/email)]])
          [{:title (t :logout)
            :options {:on-click user-handler/logout}}]

+ 1 - 1
src/main/frontend/config.cljs

@@ -27,7 +27,7 @@
 ;; dev env
 (goog-define FILE-SYNC-PROD? false)
 (goog-define LOGIN-URL
-             "https://logseq-test.auth.us-east-2.amazoncognito.com/login?client_id=4fi79en9aurclkb92e25hmu9ts&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
+             "https://logseq-test2.auth.us-east-2.amazoncognito.com/login?client_id=3ji1a0059hspovjq5fhed3uil8&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
 (goog-define API-DOMAIN "api.logseq.com")
 (goog-define WS-URL "wss://og96xf1si7.execute-api.us-east-2.amazonaws.com/production?graphuuid=%s")
 

+ 12 - 8
src/main/frontend/db/model.cljs

@@ -574,13 +574,16 @@
 
 (defn recursive-child?
   [repo child-id parent-id]
-  (loop [node (db-utils/entity repo child-id)]
-    (if node
-      (let [parent (:block/parent node)]
-        (if (= (:db/id parent) parent-id)
-          true
-          (recur parent)))
-      false)))
+  (let [*last-node (atom nil)]
+    (loop [node (db-utils/entity repo child-id)]
+      (when-not (= @*last-node node)
+        (reset! *last-node node)
+        (if node
+          (let [parent (:block/parent node)]
+            (if (= (:db/id parent) parent-id)
+              true
+              (recur parent)))
+          false)))))
 
 (defn- get-start-id-for-pagination-query
   [repo-url current-db {:keys [db-before tx-meta] :as tx-report}
@@ -606,7 +609,8 @@
                                    (if id
                                      (if (contains? match-ids id)
                                        id
-                                       (recur others))
+                                       (when (seq others)
+                                         (recur others)))
                                      nil)))))
                            (let [insert? (= :insert-blocks outliner-op)]
                              (some #(when (and (or (and insert? (not (contains? cached-ids-set %)))

+ 649 - 1
src/main/frontend/dicts.cljc

@@ -3059,6 +3059,652 @@
 
         :file-sync/other-user-graph "現在のローカルグラフは他のユーザーのリモートグラフにバインドされています。同期を開始できません。"
         :file-sync/graph-deleted "現在のリモートグラフが削除されました"}
+   
+   :it {:tutorial/text #?(:cljs (rc/inline "tutorial-en.md")
+                          :default "tutorial-en.md")
+        :tutorial/dummy-notes #?(:cljs (rc/inline "dummy-notes-en.md")
+                                 :default "dummy-notes-en.md")
+        :on-boarding/demo-graph "Questo è un diagramma dimostrativo, le modifiche non saranno salvate finchè non aprirai una cartella locale."
+        :on-boarding/add-graph "Aggiungi un diagramma"
+        :on-boarding/open-local-dir "Apri una cartella locale"
+        :on-boarding/new-graph-desc-1 "Logseq supporta sia Markdown che Org-mode. Puoi aprire una cartella esistente o crearne una nuova sul tuo dispositivo. I tuoi dati saranno salvati in questo dispositivo."
+        :on-boarding/new-graph-desc-2 "Dopo che hai aperto la tua cartella, saranno create al suo interno tre cartelle:"
+        :on-boarding/new-graph-desc-3 "/journals - conserva le tue pagine quotidiane"
+        :on-boarding/new-graph-desc-4 "/pages - conserva le altre pagine"
+        :on-boarding/new-graph-desc-5 "/logseq - conserva la configurazione, custom.css, e alcuni metadati."
+        :help/start "Per cominciare"
+        :help/about "Riguardo a Logseq"
+        :help/roadmap "Roadmap"
+        :help/bug "Riportare un errore"
+        :help/feature "Richiedere una funzione"
+        :help/changelog "Registro delle modifiche"
+        :help/blog "blog di Logseq"
+        :help/docs "Documentazione"
+        :help/privacy "Politica sulla riservatezza"
+        :help/terms "Termini"
+        :help/community "Comunità su Discord"
+        :help/awesome-logseq "Fantastico Logseq"
+        :help/shortcuts "Scorciatoie da tastiera"
+        :help/shortcuts-triggers "Inneschi"
+        :help/shortcut "Scorciatoia"
+        :help/slash-autocomplete "Barra di completamento automatico"
+        :help/block-content-autocomplete "Autocompletamento del contenuto di blocco"
+        :help/reference-autocomplete "Autocompletamente del riferimento di pagina"
+        :help/block-reference "Riferimento di blocco"
+        :help/key-commands "Comandi dei tasti"
+        :help/working-with-lists " (lavorare con le liste)"
+        :help/select-nfs-browser "Per favore utilizza un altro browser (come la versione chrome più recente) che supporta funzioni NFS per aprire la cartella locale."
+        :undo "Annulla"
+        :redo "Ripeti"
+        :general "Generale"
+        :more "Ancora"
+        :search/result-for "Cerca i risultati per "
+        :search/items "oggetti"
+        :search/page-names "Cerca nomi di pagina"
+        :help/context-menu "Menu contestuale del blocco"
+        :help/fold-unfold "Comprimi/Decomprimi blocchi (quando non sei in modalità di modifica)"
+        :help/markdown-syntax "Sintassi Markdown"
+        :help/org-mode-syntax "Sintassi Org mode"
+        :bold "Grassetto"
+        :italics "Corsivo"
+        :html-link "Link Html"
+        :highlight "Evidenzia"
+        :strikethrough "Barrato"
+        :code "Codice"
+        :right-side-bar/help "Aiuto"
+        :right-side-bar/switch-theme "Modalità tema"
+        :right-side-bar/theme "{1} tema"
+        :right-side-bar/page "Grafico della pagina"
+        :right-side-bar/recent "Recenti"
+        :right-side-bar/contents "Contenuti"
+        :right-side-bar/favorites "Preferiti"
+        :right-side-bar/page-graph "Grafico della pagina"
+        :right-side-bar/block-ref "Riferimento di Blocco"
+        :right-side-bar/graph-view "Vista del grafico"
+        :right-side-bar/all-pages "Tutte le pagine"
+        :right-side-bar/flashcards "Carte flash"
+        :right-side-bar/new-page "Nuova pagina"
+        :left-side-bar/journals "Diari"
+        :left-side-bar/new-page "Nuova pagina"
+        :left-side-bar/nav-favorites "Preferiti"
+        :left-side-bar/nav-shortcuts "Scrciatoie"
+        :left-side-bar/nav-recent-pages "Recenti"
+        :format/preferred-mode "Qual'è la tua modalità preferita?"
+        :format/markdown "Markdown"
+        :format/org-mode "Org mode"
+        :reference/linked "Riferimento collegato"
+        :reference/unlinked-ref "Riferimenti non collegati"
+        :page/presentation-mode "Presentazione"
+        :page/edit-properties-placeholder "Proprietà"
+        :page/delete-success "La pagina {1} è stata eliminata con successo!"
+        :page/delete-confirmation "Sei sicuro di voler eliminare questa pagina e i suoi dati?"
+        :page/rename-to "Rinomina \"{1}\" in:"
+        :page/priority "Priorità \"{1}\""
+        :page/copy-to-json "Copia l'intera pagina in JSON"
+        :page/rename "Rinomina pagina"
+        :page/open-in-finder "Apri nella cartella"
+        :page/open-with-default-app "Apri con la app predefinita"
+        :page/action-publish "Pubblica"
+        :page/make-public "Rendi pubblico per la pubblicazione"
+        :page/version-history "Controlla la cronologia della pagina"
+        :page/open-backup-directory "Apri la cartella dei backup delle pagine"
+        :page/file-sync-versions "Versioni delle pagine"
+        :page/make-private "Rendi privato"
+        :page/delete "Elimina pagina"
+        :page/add-to-favorites "Aggiungi ai Preferiti"
+        :page/unfavorite "Rimuovi la pagina dai Preferiti"
+        :page/show-journals "Mostra Diari"
+        :page/show-name "Mostra il nome della pagina"
+        :page/hide-name "Nascondi il nome della pagina"
+        :block/name "Nome pagina"
+        :page/last-modified "Ultima modifica alle"
+        :page/new-title "Qual'è il titolo della tua nuova pagina?"
+        :page/earlier "Prima"
+        :page/no-more-journals "Non ci sono altri diari"
+        :page/copy-page-url "Copia URL pagina"
+        :journal/multiple-files-with-different-formats "Sembra che tu abbia più file Diario (con formati diversi) per lo stesso mese, per favore conserva un solo file journal per ogni mese."
+        :journal/go-to "Vai ai file"
+        :file/name "Nome File"
+        :file/file "File: "
+        :file/last-modified-at "Ultima modifica alle"
+        :file/no-data "No dati"
+        :file/format-not-supported "Il formato .{1} non è supportato."
+        :page/created-at "Creato alle"
+        :page/updated-at "Aggiornato alle"
+        :page/backlinks "Collegamenti a ritroso"
+        :editor/block-search "Cerca un blocco"
+        :editor/image-uploading "Caricando"
+        :draw/invalid-file "Non ho potuto caricare questo file excalidraw"
+        :draw/specify-title "Per favore specifica un titolo prima!"
+        :draw/rename-success "Il file è stato rinominato con successo!"
+        :draw/rename-failure "La rinomina del file è fallita, ragione: "
+        :draw/title-placeholder "Senza titolo"
+        :draw/save "Salva"
+        :draw/save-changes "Salva modifiche"
+        :draw/new-file "Nuovo file"
+        :draw/list-files "Elenca i file"
+        :draw/delete "Elimina"
+        :draw/more-options "Altre opzioni"
+        :draw/back-to-logseq "Torna a logseq"
+        :text/image "Immagine"
+        :asset/confirm-delete "Sei sicuro di voler eliminare questo {1}?"
+        :asset/physical-delete "Rimuovi anche i file (nota che non può essere ripristinato)"
+        :content/copy "Copia"
+        :content/cut "Taglia"
+        :content/make-todos "Crea {1}"
+        :content/copy-block-ref "Copia riferimento di blocco"
+        :content/copy-block-emebed "Copia blocco incorporato"
+        :content/focus-on-block "Focus sul blocco"
+        :content/open-in-sidebar "Apri nella barra laterale"
+        :content/copy-as-json "Copia come JSON"
+        :content/click-to-edit "Clicca per modificare"
+        :settings-page/git-desc "viene utilizzato per il controllo della versione delle pagine, puoi fare clic sul menu a tre punti verticali per controllare la cronologia della pagina."
+        :settings-page/git-confirm "Devi riavviare l'app dopo aver aggiornato le impostazioni di Git."
+        :settings-page/git-switcher-label "Abilita Git auto commit"
+        :settings-page/git-commit-delay "Secondi per Git auto commit"
+        :settings-page/edit-config-edn "Modifica config.edn"
+        :settings-page/edit-custom-css "Modifica custom.css"
+        :settings-page/custom-configuration "Configurazione personalizzata"
+        :settings-page/custom-theme "Tema personalizzato"
+        :settings-page/show-brackets "Mostra parentesi"
+        :settings-page/spell-checker "Correttore ortografico"
+        :settings-page/auto-updater "Aggiornamento automatico"
+        :settings-page/disable-sentry "Invia dati di utilizzo e diagnostica a Logseq"
+        :settings-page/preferred-outdenting "Distacco logico"
+        :settings-page/custom-date-format "Formato data preferito"
+        :settings-page/preferred-file-format "Formato file preferito"
+        :settings-page/preferred-workflow "Flusso di lavoro preferito"
+        :settings-page/enable-shortcut-tooltip "Abilita suggerimenti scorciatoie"
+        :settings-page/enable-timetracking "Tracciamento del tempo"
+        :settings-page/enable-tooltip "Suggerimenti"
+        :settings-page/enable-journals "Diari"
+        :settings-page/enable-all-pages-public "Tutte le pagine pubbliche durante la pubblicazione"
+        :settings-page/customize-shortcuts "Scorciatoie di tastiera"
+        :settings-page/shortcut-settings "Personalizza scorciatoie"
+        :settings-page/home-default-page "Imposta la home page predefinita"
+        :settings-page/enable-block-time "Marche temporali sui blocchi"
+        :settings-page/clear-cache "Pulisci cache"
+        :settings-page/clear "Pulisci"
+        :settings-page/developer-mode "Modalità sviluppatore"
+        :settings-page/enable-developer-mode "Modalità sviluppatore"
+        :settings-page/disable-developer-mode "Disabilita modalità sviluppatore"
+        :settings-page/developer-mode-desc "La modalità sviluppatore aiuta i contributori e gli sviluppatori di estensioni a testare le loro integrazioni con Logseq in modo più efficiente."
+        :settings-page/current-version "Versione attuale"
+        :settings-page/current-graph "Diagramma attuale"
+        :settings-page/tab-general "Generale"
+        :settings-page/tab-editor "Editor"
+        :settings-page/tab-shortcuts "Scorciatoie"
+        :settings-page/tab-version-control "Controllo versione"
+        :settings-page/tab-advanced "Avanzate"
+        :settings-page/plugin-system "Sistema dei Plug-in"
+        :settings-page/network-proxy "Proxy di rete"
+        :logseq "Logseq"
+        :on "ON"
+        :more-options "Più opzioni"
+        :to "a"
+        :yes "Si"
+        :no "No"
+        :submit "Invia"
+        :cancel "Annulla"
+        :close "Chiudi"
+        :delete "Elimina"
+        :save "Salva"
+        :type "Tipo"
+        :host "Host"
+        :port "Porta"
+        :re-index "Re-indicizza"
+        :re-index-detail "Ricostruisci il diagramma"
+        :re-index-multiple-windows-warning "È necessario chiudere le altre finestre prima di reindicizzare questo diagramma."
+        :re-index-discard-unsaved-changes-warning "Reindicizza elimina il diagramma corrente, quindi elabora nuovamente tutti i file, poiché sono attualmente archiviati su disco. Perderai le modifiche non salvate e potrebbe volerci del tempo. Continuare?"
+        :open-new-window "Nuova finestra"
+        :sync-from-local-files "Ricarica"
+        :sync-from-local-files-detail "Importa cambiamenti da un file locale"
+        :sync-from-local-changes-detected "Ricarica rileva ed elabora i file modificati sul disco e divergenti dal contenuto effettivo della pagina Logseq. Continuare?"
+
+        :unlink "disconnetti"
+        :search/publishing "Cerca"
+        :search "Cerca o crea una pagina"
+        :page-search "Cerca nella pagina corrente"
+        :graph-search "Cerca diagramma"
+        :new-page "Nuova pagina"
+        :new-file "Nuovo file"
+        :new-graph "Aggiungi nuovo diagramma"
+        :graph "Diagramma"
+        :graph-view "Visualizza diagramma"
+        :graph/persist "Logseq sta sincronizzando lo stato interno, per favore aspetta molti secondi."
+        :graph/persist-error "Sincronizzazione dello stato interno fallita."
+        :graph/save "Salvando..."
+        :graph/save-success "Salvato con successo"
+        :graph/save-error "Salvataggio fallito"
+        :cards-view "Visualizza carte"
+        :publishing "Pubblicazione"
+        :export "Esporta"
+        :export-graph "Esporta diagramma"
+        :export-page "Esporta pagina"
+        :export-markdown "Esporta come Markdown standard (nessuna proprietà di blocco)"
+        :export-opml "Esporta come OPML"
+        :export-public-pages "Esporta pagine pubbliche"
+        :export-json "Esporta come JSON"
+        :export-roam-json "Esporta come Roam JSON"
+        :export-edn "Esporta come EDN"
+        :export-datascript-edn "Esporta datascript EDN"
+        :convert-markdown "Converti le intestazioni di Markdown in elenchi non ordinati (# -> -)"
+        :all-graphs "Tutti i diagrammi"
+        :all-pages "Tutte le pagine"
+        :all-files "Tutti i file"
+        :remove-orphaned-pages "Rimuovi pagine orfane"
+        :all-journals "Tutti i diari"
+        :my-publishing "Le mie pubblicazioni"
+        :settings "Impostazioni"
+        :settings-of-plugins "Impostazioni plugin"
+        :plugins "Plugin"
+        :themes "Temi"
+        :developer-mode-alert "È necessario riavviare l'app per abilitare il plug-in. Vuoi riavviarlo ora?"
+        :relaunch-confirm-to-work "Bisogna riavviare l'app per farla funzionare. Vuoi riavviarla ora?"
+        :import "Importa"
+        :join-community "Unisciti alla comunità"
+        :sponsor-us "Sponsorizzaci"
+        :discord-title "Il nostro gruppo Discord!"
+        :help-shortcut-title "Clicca per controllare le scorciatoie e altri suggerimenti"
+        :loading "Caricando"
+        :cloning "Clonando"
+        :parsing-files "Analizzando i file"
+        :loading-files "Caricando i file"
+        :login "Accedi"
+        :logout "Esci"
+        :go-to "Vai a "
+        :or "o"
+        :download "Scarica"
+        :repo/download-zip "Scarica tutti i file come zip"
+        :language "Lingua"
+        :white "Chiaro"
+        :dark "Scuro"
+        :remove-background "Rimuovi lo sfondo"
+        :open "Apri"
+        :open-a-directory "Apri una cartella locale"
+        :user/delete-account "Elimina account"
+        :user/delete-your-account "Elimina il tuo account"
+        :user/delete-account-notice "Tutte le tue pagine pubblicate su logseq.com saranno eliminate."
+
+        :help/shortcut-page-title "Scorciatoie di tastiera"
+
+        :plugin/installed "Installato"
+        :plugin/not-installed "Non installato"
+        :plugin/installing "Installando"
+        :plugin/install "Installa"
+        :plugin/reload "Ricarica"
+        :plugin/update "Aggiorna"
+        :plugin/check-update "Controlla aggiornamenti"
+        :plugin/check-all-updates "Controlla tutti gli aggiornamenti"
+        :plugin/refresh-lists "Ricarica liste"
+        :plugin/enabled "Abilitato"
+        :plugin/disabled "Disabilitato"
+        :plugin/update-available "Aggiornamento disponibile"
+        :plugin/updating "Aggiornando"
+        :plugin/uninstall "Disinstallare"
+        :plugin/marketplace "Mercato"
+        :plugin/downloads "Downloads"
+        :plugin/stars "Stelle"
+        :plugin/title "Titolo"
+        :plugin/all "Tutti"
+        :plugin/unpacked "Spacchettati"
+        :plugin/delete-alert "Sei sicuro di disinstallare [{1}]?"
+        :plugin/open-settings "Apri impostazioni"
+        :plugin/open-package "Apri pacchetto"
+        :plugin/load-unpacked "Carica plugin non impacchettato"
+        :plugin/open-preferences "Apri il file preferenze del plugin"
+        :plugin/restart "Riavvia App"
+        :plugin/unpacked-tips "Seleziona la cartella del plugin"
+        :plugin/contribute "✨ Scrivi e sottoponi un nuovo plugin"
+        :plugin/marketplace-tips "Se il plug-in non funziona correttamente alla prima installazione, provare a riavviare Logseq."
+        :plugin/up-to-date "È aggiornato"
+        :plugin/custom-js-alert "Trovato il file custom.js, è consentito eseguirlo? (Se non si comprende il contenuto di questo file, si consiglia di non consentire l'esecuzione, che presenta alcuni rischi per la sicurezza.)"
+
+        :pdf/copy-ref "Copia riferimenti"
+        :pdf/copy-text "Copia testo"
+        :pdf/linked-ref "Riferimenti collegati"
+        :pdf/toggle-dashed "Stile tratteggiato per evidenziare l'area"
+
+        :updater/new-version-install "Una nuova versione è stata caricata."
+        :updater/quit-and-install "Riavvia per installare"
+
+        :paginates/pages "Totale {1} pagine"
+        :paginates/prev "Precedente"
+        :paginates/next "Prossimo"
+
+        :tips/all-done "Tutto Fatto"
+
+        :command-palette/prompt "Scrivi un comando"
+        :select/default-prompt "Seleziona uno"
+        :select.graph/prompt "Seleziona un diagramma"
+        :select.graph/empty-placeholder-description "Non ci sono diagrammi corrispondenti. Vuoi aggiungerne uno nuovo?"
+        :select.graph/add-graph "Si, aggiungi un nuovo diagramma"
+
+        :file-sync/other-user-graph "Il diagramma locale attuale è associato al diagramma remoto di un altro utente. Quindi non è possibile avviare la sincronizzazione."
+        :file-sync/graph-deleted "Il diagramma attuale è stato eliminato"}
+
+   :tr {:tutorial/text #?(:cljs (rc/inline "tutorial-tr.md")
+                                :default "tutorial-tr.md")
+        :tutorial/dummy-notes #?(:cljs (rc/inline "dummy-notes-tr.md")
+                                       :default "dummy-notes-tr.md")
+        :on-boarding/demo-graph "Bu bir demo grafiktir, yerel bir klasör açana kadar değişiklikler kaydedilmeyecektir."
+        :on-boarding/add-graph "Bir grafik ekle"
+        :on-boarding/open-local-dir "Yerel bir dizin açın"
+        :on-boarding/new-graph-desc-1 "Logseq, hem Markdown hem de Org modunu destekler. Cihazınızda var olan bir dizini (klasörü) açabilir veya yeni bir tane oluşturabilirsiniz. Verileriniz yalnızca bu cihazda saklanacaktır."
+        :on-boarding/new-graph-desc-2 "Dizininizi açtıktan sonra, o dizinde üç klasör oluşturacaktır:"
+        :on-boarding/new-graph-desc-3 "/journals - günlük sayfalarınız saklanır"
+        :on-boarding/new-graph-desc-4 "/pages - diğer sayfalarınız saklanır"
+        :on-boarding/new-graph-desc-5 "/logseq - yapılandırma, custom.css ve bazı meta veriler saklanır."
+        :help/start "Başlarken"
+        :help/about "Logseq hakkında"
+        :help/roadmap "Yol haritası"
+        :help/bug "Hata raporu"
+        :help/feature "Özellik talebi"
+        :help/changelog "Değişiklik günlüğü"
+        :help/blog "Logseq blogu"
+        :help/docs "Belgeler"
+        :help/privacy "Gizlilik ilkesi"
+        :help/terms "Koşullar"
+        :help/community "Discord topluluğu"
+        :help/awesome-logseq "Awesome Logseq"
+        :help/shortcuts "Klavye kısayolları"
+        :help/shortcuts-triggers "Tetikleyiciler"
+        :help/shortcut "Kısayol"
+        :help/slash-autocomplete "Eğik çizgi otomatik tamamlama"
+        :help/block-content-autocomplete "Blok içeriği otomatik tamamlama"
+        :help/reference-autocomplete "Sayfa referansı otomatik tamamlama"
+        :help/block-reference "Blok referansı"
+        :help/key-commands "Tuş komutları"
+        :help/working-with-lists " (listelerle çalışır)"
+        :help/select-nfs-browser "Lütfen yerel dizini açmak için NFS özelliklerini destekleyen başka bir tarayıcı (en son chrome gibi) kullanın."
+        :undo "Geri al"
+        :redo "Yinele"
+        :general "Genel"
+        :more "Daha fazla"
+        :search/result-for "Arama sonucu: "
+        :search/items "öğe"
+        :search/page-names "Sayfa adlarında ara"
+        :help/context-menu "Blok kısayol menüsü"
+        :help/fold-unfold "Blokları katla/aç  (düzenleme modunda değilken)"
+        :help/markdown-syntax "Markdown sözdizimi"
+        :help/org-mode-syntax "Org modu sözdizimi"
+        :bold "Kalın"
+        :italics "İtalik"
+        :html-link "Html Bağlantısı"
+        :highlight "Vurgulu"
+        :strikethrough "Üstü çizili"
+        :code "Kod"
+        :right-side-bar/help "Yardım"
+        :right-side-bar/switch-theme "Tema modları"
+        :right-side-bar/theme "{1} tema"
+        :right-side-bar/page "Sayfa grafiği"
+        :right-side-bar/recent "En son"
+        :right-side-bar/contents "İçindekiler"
+        :right-side-bar/favorites "Sık kullanılanlar"
+        :right-side-bar/page-graph "Sayfa grafiği"
+        :right-side-bar/block-ref "Blok referansı"
+        :right-side-bar/graph-view "Grafik görünümü"
+        :right-side-bar/all-pages "Bütün sayfalar"
+        :right-side-bar/flashcards "Bilgi kartları"
+        :right-side-bar/new-page "Yeni sayfa"
+        :left-side-bar/journals "Günlük"
+        :left-side-bar/new-page "Yeni sayfa"
+        :left-side-bar/nav-favorites "Sık kullanılanlar"
+        :left-side-bar/nav-shortcuts "Kısa yollar"
+        :left-side-bar/nav-recent-pages "En son"
+        :format/preferred-mode "Tercih ettiğiniz mod nedir?"
+        :format/markdown "Markdown"
+        :format/org-mode "Org modu"
+        :reference/linked "Bağlantılı referans"
+        :reference/unlinked-ref "Bağlantısız referanslar"
+        :page/presentation-mode "Sunu"
+        :page/edit-properties-placeholder "Özellikler"
+        :page/delete-success "{1} sayfası başarıyla silindi!"
+        :page/delete-confirmation "Bu sayfayı ve dosyasını silmek istediğinizden emin misiniz?"
+        :page/rename-to "\"{1}\" öğesini yeniden adlandır:"
+        :page/priority "Öncelik \"{1}\""
+        :page/copy-to-json "Tüm sayfayı JSON olarak kopyala"
+        :page/rename "Sayfayı yeniden adlandır"
+        :page/open-in-finder "Dizini aç"
+        :page/open-with-default-app "Varsayılan uygulamayla aç"
+        :page/action-publish "Yayımla"
+        :page/make-public "Yayımlamak için herkese açık hale getir"
+        :page/version-history "Sayfa geçmişini kontrol et"
+        :page/open-backup-directory "Sayfa yedekleme dizinini aç"
+        :page/file-sync-versions "Sayfa sürümleri"
+        :page/make-private "Özel yap"
+        :page/delete "Sayfayı sil"
+        :page/add-to-favorites "Sık kullanılanlara ekle"
+        :page/unfavorite "Sayfayı sık kullanılanlardan kaldır"
+        :page/show-journals "Günlükleri göster"
+        :page/show-name "Sayfa adını göster"
+        :page/hide-name "Sayfa adını gizle"
+        :block/name "Sayfa adı"
+        :page/last-modified "Son değiştirilme tarihi"
+        :page/new-title "Yeni sayfa başlığınız nedir?"
+        :page/earlier "Daha önce"
+        :page/no-more-journals "Başka günlük yok"
+        :page/copy-page-url "Sayfa URL adresini kopyala"
+        :journal/multiple-files-with-different-formats "Aynı ay için birden fazla günlük dosyanız (farklı formatlarda) var gibi görünüyor, lütfen her ay için yalnızca bir günlük dosyası tutun."
+        :journal/go-to "Dosyalara git"
+        :file/name "Dosya adı"
+        :file/file "Dosya: "
+        :file/last-modified-at "Son değiştirilme tarihi"
+        :file/no-data "Veri yok"
+        :file/format-not-supported ".{1} biçimi desteklenmiyor."
+        :page/created-at "Oluşturulma Zamanı"
+        :page/updated-at "Güncellenme Zamanı"
+        :page/backlinks "Geri Bağlantılar"
+        :editor/block-search "Blok ara"
+        :editor/image-uploading "Karşıya yükleniyor"
+        :draw/invalid-file "Bu geçersiz excalidraw dosyası yüklenemedi"
+        :draw/specify-title "Lütfen önce bir başlık belirtin!"
+        :draw/rename-success "Dosya başarıyla yeniden adlandırıldı!"
+        :draw/rename-failure "Dosya yeniden adlandırılamadı, nedeni: "
+        :draw/title-placeholder "Adsız"
+        :draw/save "Kaydet"
+        :draw/save-changes "Değişiklikleri kaydet"
+        :draw/new-file "Yeni dosya"
+        :draw/list-files "Dosyaları listele"
+        :draw/delete "Sil"
+        :draw/more-options "Diğer seçenekler"
+        :draw/back-to-logseq "Logseq'e geri dön"
+        :text/image "Resim"
+        :asset/confirm-delete "Bu resmi silmek istediğinizden emin misiniz?"
+        :asset/physical-delete "Dosyayı da kaldırın (geri getirilemeyeceğine dikkat edin)"
+        :content/copy "Kopyala"
+        :content/cut "Kes"
+        :content/make-todos "{1} yap"
+        :content/copy-block-ref "Blok referansını kopyala"
+        :content/copy-block-emebed "Blok eklemesini kopyala"
+        :content/focus-on-block "Bloğa odaklan"
+        :content/open-in-sidebar "Kenar çubuğunda aç"
+        :content/copy-as-json "JSON olarak kopyala"
+        :content/click-to-edit "Düzenlemek için tıklayın"
+        :settings-page/git-desc "sayfaların sürüm kontrolü için kullanılır, sayfanın geçmişini kontrol etmek için dikey üç nokta menüsüne tıklayabilirsiniz."
+        :settings-page/git-confirm "Git ayarlarını güncelledikten sonra uygulamayı yeniden başlatmanız gerekiyor."
+        :settings-page/git-switcher-label "Git otomatik commit'i etkinleştir"
+        :settings-page/git-commit-delay "Git otomatik commit saniyesi"
+        :settings-page/edit-config-edn "config.edn dosyasını düzenle"
+        :settings-page/edit-custom-css "custom.css dosyasını düzenle"
+        :settings-page/custom-configuration "Özel yapılandırma"
+        :settings-page/custom-theme "Özel tema"
+        :settings-page/show-brackets "Köşeli ayraçları göster"
+        :settings-page/spell-checker "Yazım denetleyici"
+        :settings-page/auto-updater "Otomatik güncelleme"
+        :settings-page/disable-sentry "Kullanım verilerini ve tanılamayı Logseq'e gönderin"
+        :settings-page/preferred-outdenting "mantıksal girinti"
+        :settings-page/custom-date-format "Tercih edilen tarih biçimi"
+        :settings-page/preferred-file-format "Tercih edilen dosya biçimi"
+        :settings-page/preferred-workflow "Tercih edilen iş akışı"
+        :settings-page/enable-shortcut-tooltip "Kısayol araç ipuçlarını etkinleştir"
+        :settings-page/enable-timetracking "Zaman takibi"
+        :settings-page/enable-tooltip "Araç ipuçları"
+        :settings-page/enable-journals "Günlük"
+        :settings-page/enable-all-pages-public "Yayımlanan tüm sayfaları herkese açık yap"
+        :settings-page/customize-shortcuts "Klavye kısayolları"
+        :settings-page/shortcut-settings "Kısayolları özelleştir"
+        :settings-page/home-default-page "Varsayılan ana sayfayı ayarla"
+        :settings-page/enable-block-time "Blok zaman damgaları"
+        :settings-page/clear-cache "Önbelleği temizle"
+        :settings-page/clear "Temizle"
+        :settings-page/developer-mode "Geliştirici modu"
+        :settings-page/enable-developer-mode "Geliştirici modu"
+        :settings-page/disable-developer-mode "Geliştirici modunu devre dışı bırak"
+        :settings-page/developer-mode-desc "Geliştirici modu, katkıda bulunanların ve eklenti geliştiricilerinin Logseq ile entegrasyonlarını daha verimli bir şekilde test etmesine yardımcı olur."
+        :settings-page/current-version "Geçerli sürüm"
+        :settings-page/current-graph "Geçerli grafik"
+        :settings-page/tab-general "Genel"
+        :settings-page/tab-editor "Düzenleyici"
+        :settings-page/tab-shortcuts "Kısayollar"
+        :settings-page/tab-version-control "Sürüm denetimi"
+        :settings-page/tab-advanced "Gelişmiş"
+        :settings-page/plugin-system "Eklenti sistemi"
+        :settings-page/network-proxy "Ağ ara sunucusu"
+        :logseq "Logseq"
+        :on "AÇIK"
+        :more-options "Diğer seçenekler"
+        :to "hedef:"
+        :yes "Evet"
+        :no "Hayır"
+        :submit "Onayla"
+        :cancel "İptal"
+        :close "Kapat"
+        :delete "Sil"
+        :save "Kaydet"
+        :type "Tür"
+        :host "Ana Bilgisayar"
+        :port "Bağlantı Noktası"
+        :re-index "Yeniden dizin oluştur"
+        :re-index-detail "Grafiği yeniden oluştur"
+        :re-index-multiple-windows-warning "Bu grafik için yeniden dizin oluşturmadan önce diğer pencereleri kapatmanız gerekiyor."
+        :re-index-discard-unsaved-changes-warning "Yeniden dizin oluşturmak mevcut grafiği siler ve ardından tüm dosyaları o anda diskte depolandıkları şekilde yeniden işler. Kaydedilmemiş değişiklikleri kaybedeceksiniz ve bu biraz zaman alabilir. Devam edilsin mi?"
+        :open-new-window "Yeni pencere"
+        :sync-from-local-files "Yenile"
+        :sync-from-local-files-detail "Yerel dosyalardan değişiklikleri içeri aktarın"
+        :sync-from-local-changes-detected "Yenile, diskinizde değiştirilen ve gerçek Logseq sayfa içeriğinden ayrılan dosyaları algılar ve işler. Devam edilsin mi?"
+
+        :unlink "bağlantıyı kaldır"
+        :search/publishing "Ara"
+        :search "Ara veya sayfa oluştur"
+        :page-search "Geçerli sayfada ara"
+        :graph-search "Grafikte ara"
+        :new-page "Yeni sayfa"
+        :new-file "Yeni dosya"
+        :new-graph "Yeni grafik ekle"
+        :graph "Grafik"
+        :graph-view "Grafiği görüntüle"
+        :graph/persist "Logseq dahili durumu senkronize ediyor, lütfen birkaç saniye bekleyin."
+        :graph/persist-error "Dahili durum senkronize edilemedi."
+        :graph/save "Kaydediliyor..."
+        :graph/save-success "Başarıyla Kaydedildi"
+        :graph/save-error "Kaydedilemedi"
+        :cards-view "Kartları görüntüle"
+        :publishing "Yayımlama"
+        :export "Dışarı aktar"
+        :export-graph "Grafiği dışarı aktar"
+        :export-page "Sayfayı dışarı aktar"
+        :export-markdown "Standart Markdown olarak dışarı aktar (blok özelliği yok)"
+        :export-opml "OPML olarak dışarı aktar"
+        :export-public-pages "Herkese açık sayfaları dışarı aktar"
+        :export-json "JSON olarak dışarı aktar"
+        :export-roam-json "Roam JSON olarak dışarı aktar"
+        :export-edn "EDN olarak dışarı aktar"
+        :export-datascript-edn "EDN datascript öğesini dışarı aktar"
+        :convert-markdown "Markdown başlıklarını sırasız listelere dönüştürün (# -> -)"
+        :all-graphs "Tüm grafikler"
+        :all-pages "Tüm sayfalar"
+        :all-files "Tüm dosyalar"
+        :remove-orphaned-pages "Yalnız bırakılmış sayfaları kaldır"
+        :all-journals "Bütün günlükler"
+        :my-publishing "Yayımlarım"
+        :settings "Ayarlar"
+        :settings-of-plugins "Eklenti ayarları"
+        :plugins "Eklentiler"
+        :themes "Temelar"
+        :developer-mode-alert "Eklenti sistemini etkinleştirmek için uygulamayı yeniden başlatmanız gerekir. Şimdi yeniden başlatmak istiyor musunuz?"
+        :relaunch-confirm-to-work "Çalışması için uygulama yeniden başlatılmalı. Şimdi yeniden başlatmak istiyor musunuz?"
+        :import "İçe aktar"
+        :join-community "Topluluğa katıl"
+        :sponsor-us "Bize Sponsor Olun"
+        :discord-title "Discord grubumuz!"
+        :help-shortcut-title "Kısayolları ve diğer ipuçlarını kontrol etmek için tıklayın"
+        :loading "Yükleniyor"
+        :cloning "Kopyalanıyor"
+        :parsing-files "Dosyalar ayrıştırılıyor"
+        :loading-files "Dosyalar yükleniyor"
+        :login "Oturum aç"
+        :logout "Oturumu kapat"
+        :go-to "Git: "
+        :or "veya"
+        :download "İndir"
+        :repo/download-zip "Tüm dosyaları zip olarak indir"
+        :language "Dil"
+        :white "Açık"
+        :dark "Koyu"
+        :remove-background "Arka planı kaldır"
+        :open "Aç"
+        :open-a-directory "Yerel bir dizin aç"
+        :user/delete-account "Hesabı sil"
+        :user/delete-your-account "Hesabınızı silin"
+        :user/delete-account-notice "logseq.com adresinde yayımlanan tüm sayfalarınız silinecek."
+
+        :help/shortcut-page-title "Klavye kısayolları"
+
+        :plugin/installed "Yüklü"
+        :plugin/not-installed "Yüklü olmayan"
+        :plugin/installing "Yükleniyor"
+        :plugin/install "Yükle"
+        :plugin/reload "Yeniden yükle"
+        :plugin/update "Güncelle"
+        :plugin/check-update "Güncellemeyi denetle"
+        :plugin/check-all-updates "Tüm güncellemeleri denetle"
+        :plugin/refresh-lists "Listeleri yenile"
+        :plugin/enabled "Etkin"
+        :plugin/disabled "Devre dışı"
+        :plugin/update-available "Güncelleme var"
+        :plugin/updating "Güncelleniyor"
+        :plugin/uninstall "Kaldır"
+        :plugin/marketplace "Market"
+        :plugin/downloads "İndirilme"
+        :plugin/stars "Yıldızlar"
+        :plugin/title "Başlık"
+        :plugin/all "Tümü"
+        :plugin/unpacked "Çıkarılmamış"
+        :plugin/delete-alert "[{1}] eklentisini kaldırmak istediğinizden emin misiniz?"
+        :plugin/open-settings "Ayarları aç"
+        :plugin/open-package "Eklentinin içeriğini aç"
+        :plugin/load-unpacked "Çıkarılmamış eklentiyi yükle"
+        :plugin/open-preferences "Eklenti tercihleri dosyasını açın"
+        :plugin/restart "Uygulamayı Yeniden Başlat"
+        :plugin/unpacked-tips "Eklenti dizinini seçin"
+        :plugin/contribute "✨ Yeni eklenti yaz ve gönder"
+        :plugin/marketplace-tips "Eklenti ilk kurulduğunda düzgün çalışmıyorsa Logseq'i yeniden başlatmayı deneyin."
+        :plugin/up-to-date "Güncel"
+        :plugin/custom-js-alert "custom.js dosyası bulundu, çalıştırılmasına izin veriliyor mu? (Bu dosyanın içeriğini anlamadıysanız, belirli güvenlik riskleri olduğu için çalıştırmaya izin vermemeniz önerilir.)"
+
+        :pdf/copy-ref "Referansı kopyala"
+        :pdf/copy-text "Metni kopyala"
+        :pdf/linked-ref "Bağlantılı referans"
+        :pdf/toggle-dashed "Alan vurgusu için çizgili stil"
+
+        :updater/new-version-install "Yeni bir sürüm indirildi."
+        :updater/quit-and-install "Yüklemek için yeniden başlatın"
+
+        :paginates/pages "Toplam {1} sayfa"
+        :paginates/prev "Önceki"
+        :paginates/next "Sonraki"
+
+        :tips/all-done "Tamamlandı"
+
+        :command-palette/prompt "Bir komut yazın"
+        :select/default-prompt "Birini seçin"
+        :select.graph/prompt "Bir grafik seçin"
+        :select.graph/empty-placeholder-description "Eşleşen grafik yok. Bir tane daha eklemek ister misin?"
+        :select.graph/add-graph "Evet, başka bir grafik ekle"
+
+        :file-sync/other-user-graph "Geçerli yerel grafik, diğer kullanıcının uzak grafiğine bağlıdır. Bu yüzden senkronizasyon başlatılamıyor."
+        :file-sync/graph-deleted "Geçerli uzak grafik silindi"}
 
    :tongue/fallback :en})
 
@@ -3073,4 +3719,6 @@
                 {:label "Português (Brasileiro)" :value :pt-BR}
                 {:label "Português (Europeu)" :value :pt-PT}
                 {:label "Русский" :value :ru}
-                {:label "日本語" :value :ja}])
+                {:label "日本語" :value :ja}
+                {:label "Italiano" :value :it}
+                {:label "Türkçe" :value :tr}])

+ 1 - 1
src/main/frontend/extensions/excalidraw.cljs

@@ -35,7 +35,7 @@
 
 (defn- update-draw-content-width
   [state]
-  (let [el ^js (rum/dom-node state)]
+  (when-let [el ^js (rum/dom-node state)]
     (loop [el (.querySelector el ".draw-wrap")]
       (cond
         (or (nil? el) (undefined? el) (undefined? (.-classList el)))

+ 17 - 14
src/main/frontend/extensions/latex.cljs

@@ -12,7 +12,7 @@
 (defn loaded? []
   js/window.katex)
 
-(defonce *loading? (atom true))
+(defonce *loading? (atom false))
 
 (defn render!
   [state]
@@ -31,7 +31,7 @@
     (do
       (reset! *loading? false)
       (render! state))
-    (do
+    (when-not @*loading?
       (reset! *loading? true)
       (loader/load
        (config/asset-uri "/static/js/katex.min.js")
@@ -48,21 +48,24 @@
                 (render! state))))))
        state))))
 
+(defn- state-&-load-and-render!
+  [state]
+  (js/setTimeout #(load-and-render! state) 10)
+  state)
+
 (rum/defc latex < rum/reactive
-  {:did-mount (fn [state]
-                (js/setTimeout #(load-and-render! state) 0)
-                state)
-   :did-update load-and-render!}
+  {:did-mount  state-&-load-and-render!
+   :did-update state-&-load-and-render!}
   [id s block? _display?]
   (let [loading? (rum/react *loading?)]
-    (when loading?
-      (ui/loading "Loading"))
-    (let [element (if block?
-                    :div.latex
-                    :span.latex-inline)]
-      [element {:id id
-                :class (if loading? "hidden" "initial")}
-       s])))
+    (if loading?
+      (ui/loading "Loading")
+      (let [element (if block?
+                      :div.latex
+                      :span.latex-inline)]
+        [element {:id    id
+                  :class "initial"}
+         [:span.opacity-0 s]]))))
 
 (defn html-export
   [s block? display?]

+ 115 - 9
src/main/frontend/fs/sync.cljs

@@ -8,10 +8,12 @@
             [clojure.set :as set]
             [clojure.string :as string]
             [electron.ipc :as ipc]
+            [goog.string :as gstring]
             [frontend.config :as config]
             [frontend.debug :as debug]
             [frontend.handler.user :as user]
             [frontend.state :as state]
+            [frontend.mobile.util :as mobile-util]
             [frontend.util :as util]
             [frontend.util.persist-var :as persist-var]
             [frontend.handler.notification :as notification]
@@ -158,7 +160,8 @@
   (go
     (let [resp (http/post (str "https://" config/API-DOMAIN "/file-sync/" api-name)
                           {:oauth-token token
-                           :body (js/JSON.stringify (clj->js body))})]
+                           :body (js/JSON.stringify (clj->js body))
+                           :with-credentials? false})]
       {:resp (<! resp)
        :api-name api-name
        :body body})))
@@ -179,7 +182,9 @@
          (:resp resp))))))
 
 (defn- remove-dir-prefix [dir path]
-  (let [r (string/replace path (js/RegExp. (str "^" dir)) "")]
+  (let [is-mobile-url? (string/starts-with? dir "file://")
+        dir (if is-mobile-url? (gstring/urlDecode dir) dir)
+        r (string/replace path (js/RegExp. (str "^" (gstring/regExpEscape dir))) "")]
     (if (string/starts-with? r "/")
       (string/replace-first r "/" "")
       r)))
@@ -419,7 +424,7 @@
                (string/index-of (str (ex-cause r)) "operation timed out")
                (> n 0))
         (do
-          (prn (str "retry(" n ") ..."))
+          (print (str "retry(" n ") ..."))
           (recur (dec n)))
         r))))
 
@@ -490,7 +495,108 @@
          (retry-rsapi
           #(p->c (ipc/ipc "delete-remote-files" graph-uuid base-path filepaths local-txid token))))))))
 
-(def rsapi (->RSAPI))
+(deftype CapacitorAPI []
+  IToken
+  (get-token [this]
+    (go
+      (or (state/get-auth-id-token)
+          (<! (.refresh-token this)))))
+  (refresh-token [_]
+    (go
+      (<! (user/refresh-id-token&access-token))
+      (state/get-auth-id-token)))
+
+  IRSAPI
+  (set-env [_ prod?]
+    (go (<! (p->c (.setEnv mobile-util/file-sync (clj->js {:env (if prod? "prod" "dev")}))))))
+
+  (get-local-all-files-meta [_ _graph-uuid base-path]
+    (go
+      (let [r (<! (p->c (.getLocalAllFilesMeta mobile-util/file-sync (clj->js {:basePath base-path}))))]
+        (if (instance? ExceptionInfo r)
+          r
+          (->> (.-result r)
+               js->clj
+               (map (fn [[path metadata]]
+                      (->FileMetadata (get metadata "size") (get metadata "md5") path nil false nil)))
+               set)))))
+
+  (get-local-files-meta [_ _graph-uuid base-path filepaths]
+    (go
+      (let [r (<! (p->c (.getLocalFilesMeta mobile-util/file-sync
+                                            (clj->js {:basePath base-path
+                                                      :filePaths filepaths}))))]
+        (if (instance? ExceptionInfo r)
+          r
+          (->> (.-result r)
+               js->clj
+               (map (fn [[path metadata]]
+                      (->FileMetadata (get metadata "size") (get metadata "md5") path nil false nil)))
+               set)))))
+
+  (rename-local-file [_ _graph-uuid base-path from to]
+    (go
+      (<! (p->c (.renameLocalFile mobile-util/file-sync
+                                  (clj->js {:basePath base-path
+                                            :from from
+                                            :to to}))))))
+
+  (update-local-files [this graph-uuid base-path filepaths]
+    (go
+      (let [token (<! (get-token this))
+            r (<! (retry-rsapi
+                   #(p->c (.updateLocalFiles mobile-util/file-sync (clj->js {:graphUUID graph-uuid
+                                                                             :basePath base-path
+                                                                             :filePaths filepaths
+                                                                             :token token})))))]
+        (when (state/developer-mode?) (check-files-exists base-path filepaths))
+        r)))
+
+  (delete-local-files [_ _graph-uuid base-path filepaths]
+    (go
+      (let [r (<! (retry-rsapi #(p->c (.deleteLocalFiles mobile-util/file-sync
+                                                         (clj->js {:basePath base-path
+                                                                   :filePaths filepaths})))))]
+        (when (state/developer-mode?) (check-files-not-exists base-path filepaths))
+        r)))
+
+  (update-remote-file [this graph-uuid base-path filepath local-txid]
+    (update-remote-files this graph-uuid base-path [filepath] local-txid))
+
+  (update-remote-files [this graph-uuid base-path filepaths local-txid]
+    (go
+      (let [token (<! (get-token this))
+            r (<! (p->c (.updateRemoteFiles mobile-util/file-sync
+                                            (clj->js {:graphUUID graph-uuid
+                                                      :basePath base-path
+                                                      :filePaths filepaths
+                                                      :txid local-txid
+                                                      :token token}))))]
+        (prn ::debug-update-remote-files r)
+        (if (instance? ExceptionInfo r)
+          r
+          (get (js->clj r) "txid")))))
+
+  (delete-remote-files [this graph-uuid _base-path filepaths local-txid]
+    (let [token (<! (get-token this))
+          r (<! (p->c (.deleteRemoteFiles mobile-util/file-sync
+                                          (clj->js {:graphUUID graph-uuid
+                                                    :filePaths filepaths
+                                                    :txid local-txid
+                                                    :token token}))))]
+      (if (instance? ExceptionInfo r)
+        r
+        (get (js->clj r) "txid")))))
+
+(def rsapi (cond
+             (util/electron?)
+             (->RSAPI)
+
+             (mobile-util/native-ios?)
+             (->CapacitorAPI)
+
+             :else
+             nil))
 
 (deftype RemoteAPI []
   Object
@@ -713,11 +819,11 @@
 (defn file-watch-handler
   "file-watcher callback"
   [type {:keys [dir path _content stat] :as _payload}]
-  (go
-    (when (some-> (state/get-file-sync-state)
-                  sync-state--stopped?
-                  not)
-      (>! local-changes-chan (->FileChangeEvent type dir path stat)))))
+
+  (when (some-> (state/get-file-sync-state)
+                sync-state--stopped?
+                not)
+    (go (>! local-changes-chan (->FileChangeEvent type dir path stat)))))
 
 ;;; ### remote->local syncer & local->remote syncer
 

+ 47 - 19
src/main/frontend/handler/editor.cljs

@@ -651,7 +651,10 @@
                                                           :keep-uuid? true
                                                           :replace-empty-target? replace-empty-target?})
             (when edit-block?
-              (js/setTimeout #(edit-block! new-block :max (:block/uuid new-block)) 10))
+              (if (and replace-empty-target?
+                       (string/blank? (:block/content last-block)))
+                (js/setTimeout #(edit-block! last-block :max (:block/uuid last-block)) 10)
+                (js/setTimeout #(edit-block! new-block :max (:block/uuid new-block)) 10)))
             new-block))))))
 
 (defn insert-first-page-block-if-not-exists!
@@ -1888,6 +1891,16 @@
             :block/path-refs (->> (cons (:db/id page) (:block/path-refs block))
                                   (remove nil?))})))
 
+(defn- edit-last-block-after-inserted!
+  [result]
+  (js/setTimeout
+   (fn []
+     (when-let [last-block (last (:blocks result))]
+       (clear-when-saved!)
+       (let [last-block' (db/pull [:block/uuid (:block/uuid last-block)])]
+         (edit-block! last-block' :max (:block/uuid last-block')))))
+   0))
+
 (defn paste-blocks
   [blocks {:keys [content-update-fn
                   exclude-properties
@@ -1921,13 +1934,7 @@
                              (paste-block-cleanup block page exclude-properties format content-update-fn))
                         blocks)
               result (outliner-core/insert-blocks! blocks' target-block {:sibling? sibling?})]
-          (js/setTimeout
-           (fn []
-             (when-let [last-block (last (:blocks result))]
-               (clear-when-saved!)
-               (let [last-block' (db/pull [:block/uuid (:block/uuid last-block)])]
-                 (edit-block! last-block' :max (:block/uuid last-block')))))
-           0))))))
+          (edit-last-block-after-inserted! result))))))
 
 (defn- block-tree->blocks
   [tree-vec format]
@@ -1959,11 +1966,12 @@
 (defn insert-template!
   ([element-id db-id]
    (insert-template! element-id db-id {}))
-  ([element-id db-id opts]
+  ([element-id db-id {:keys [target] :as opts}]
    (when-let [db-id (if (integer? db-id)
                       db-id
                       (:db/id (db-model/get-template-by-name (name db-id))))]
      (let [repo (state/get-current-repo)
+           target (or target (state/get-edit-block))
            block (db/entity db-id)
            format (:block/format block)
            block-uuid (:block/uuid block)
@@ -1977,20 +1985,40 @@
                     (drop 1 sorted-blocks))]
        (when element-id
          (insert-command! element-id "" format {}))
-       (let [opts (merge
-                   {:exclude-properties [:id :template :template-including-parent]
-                    :content-update-fn (fn [content]
-                                         (->> content
-                                              (property/remove-property format "template")
-                                              (property/remove-property format "template-including-parent")
-                                              template/resolve-dynamic-template!))}
-                   opts)]
-         (paste-blocks blocks opts))))))
+       (let [exclude-properties [:id :template :template-including-parent]
+             content-update-fn (fn [content]
+                                 (->> content
+                                      (property/remove-property format "template")
+                                      (property/remove-property format "template-including-parent")
+                                      template/resolve-dynamic-template!))
+             page (if (:block/name block) block
+                      (when target (:block/page (db/entity (:db/id target)))))
+             blocks' (map (fn [block]
+                            (paste-block-cleanup block page exclude-properties format content-update-fn))
+                       blocks)
+             sibling? (:sibling? opts)
+             sibling?' (cond
+                         (some? sibling?)
+                         sibling?
+
+                         (db/has-children? (:block/uuid target))
+                         false
+
+                         :else
+                         true)]
+         (outliner-tx/transact!
+           {:outliner-op :insert-blocks}
+           (save-current-block!)
+           (let [result (outliner-core/insert-blocks! blocks'
+                                                      target
+                                                      (assoc opts :sibling? sibling?'))]
+             (edit-last-block-after-inserted! result))))))))
 
 (defn template-on-chosen-handler
   [element-id]
   (fn [[_template db-id] _click?]
-    (insert-template! element-id db-id)))
+    (insert-template! element-id db-id
+                      {:replace-empty-target? true})))
 
 (defn parent-is-page?
   [{{:block/keys [parent page]} :data :as node}]

+ 7 - 5
src/main/frontend/handler/events.cljs

@@ -341,11 +341,13 @@
          false)))))
 
 (defmethod handle :file-watcher/changed [[_ ^js event]]
-  (fs-watcher/handle-changed!
-   (.-event event)
-   (update (js->clj event :keywordize-keys true)
-           :path
-           js/decodeURI)))
+  (let [type (.-event event)
+        payload (js->clj event :keywordize-keys true)
+        payload' (-> payload
+                     (update :path js/decodeURI))]
+    (prn ::fs-watcher payload)
+    (fs-watcher/handle-changed! type payload')
+    (sync/file-watch-handler type payload')))
 
 (defmethod handle :rebuild-slash-commands-list [[_]]
   (page-handler/rebuild-slash-commands-list!))

+ 4 - 2
src/main/frontend/handler/user.cljs

@@ -81,7 +81,8 @@
 
 (defn login-callback [code]
   (go
-    (let [resp (<! (http/get (str "https://" config/API-DOMAIN "/auth_callback?code=" code)))]
+    (let [resp (<! (http/get (str "https://" config/API-DOMAIN "/auth_callback?code=" code)
+                             {:with-credentials? false}))]
       (if (= 200 (:status resp))
         (-> resp
               (:body)
@@ -99,7 +100,8 @@
   []
   (when-let [refresh-token (state/get-auth-refresh-token)]
     (go
-      (let [resp (<! (http/get (str "https://" config/API-DOMAIN "/auth_refresh_token?refresh_token=" refresh-token)))]
+      (let [resp (<! (http/get (str "https://" config/API-DOMAIN "/auth_refresh_token?refresh_token=" refresh-token)
+                               {:with-credentials? false}))]
         (if (= 400 (:status resp))
           ;; invalid refresh_token
           (do

+ 17 - 2
src/main/frontend/mobile/core.cljs

@@ -9,6 +9,7 @@
             [clojure.string :as string]
             [frontend.fs.capacitor-fs :as fs]
             [frontend.handler.editor :as editor-handler]
+            [frontend.handler.user :as user-handler]
             [frontend.util :as util]))
 
 (defn- ios-init
@@ -48,7 +49,21 @@
                          (js/window.history.back))))))
 
   (when (mobile-util/native-ios?)
-    (ios-init))
+    (ios-init)
+    (.removeAllListeners mobile-util/file-sync)
+
+    (.addListener App "appUrlOpen"
+                  (fn [^js data]
+                    (when-let [url (.-url data)]
+                      ;; TODO: handler other logseq:// URLs
+                      (when (string/starts-with? url "logseq://auth-callback")
+                        (let [parsed-url (js/URL. url)
+                              code (.get (.-searchParams parsed-url) "code")]
+                          (user-handler/login-callback code))))))
+
+    (.addListener mobile-util/file-sync "debug"
+                  (fn [event]
+                    (js/console.log "🔄" event))))
 
   (when (mobile-util/is-native-platform?)
     (.addListener mobile-util/fs-watcher "watcher"
@@ -66,4 +81,4 @@
                           (editor-handler/save-current-block!))))))
 
     (.addEventListener js/window "sendIntentReceived"
-                       #(intent/handle-received))))
+                       #(intent/handle-received))))

+ 4 - 8
src/main/frontend/mobile/footer.cljs

@@ -54,14 +54,10 @@
      (mobile-bar-command #(state/toggle-document-mode!) "notes")
      (mobile-bar-command
       #(let [page (or (state/get-current-page)
-                      (string/lower-case (date/journal-name)))
-             block (editor-handler/api-insert-new-block!
+                      (string/lower-case (date/journal-name)))]
+         (editor-handler/api-insert-new-block!
                     ""
                     {:page page
-                     :replace-empty-target? true})]
-         (js/setTimeout
-          (fn [] (editor-handler/edit-block!
-                  block
-                  :max
-                  (:block/uuid block))) 100))
+                     :edit-block? true
+                     :replace-empty-target? true}))
       "edit")]))

+ 3 - 0
src/main/frontend/mobile/intent.cljs

@@ -53,6 +53,7 @@
     (if (state/get-edit-block)
       (state/append-current-edit-content! values)
       (editor-handler/api-insert-new-block! values {:page page
+                                                    :edit-block? false
                                                     :replace-empty-target? true}))))
 
 (defn- embed-asset-file [url format]
@@ -99,6 +100,7 @@
     (if (state/get-edit-block)
       (state/append-current-edit-content! content)
       (editor-handler/api-insert-new-block! content {:page page
+                                                     :edit-block? false
                                                      :replace-empty-target? true}))))
 
 (defn- handle-received-application [result]
@@ -124,6 +126,7 @@
     (if (state/get-edit-block)
       (state/append-current-edit-content! content)
       (editor-handler/api-insert-new-block! content {:page page
+                                                     :edit-block? false
                                                      :replace-empty-target? true}))))
 
 (defn decode-received-result [m]

+ 1 - 0
src/main/frontend/mobile/record.cljs

@@ -60,6 +60,7 @@
     (if edit-block
       (state/append-current-edit-content! file-link)
       (editor-handler/api-insert-new-block! file-link {:page page
+                                                       :edit-block? false
                                                        :replace-empty-target? true}))))
 
 (defn stop-recording []

+ 3 - 1
src/main/frontend/mobile/util.cljs

@@ -23,7 +23,9 @@
 (defonce folder-picker (registerPlugin "FolderPicker"))
 (when (native-ios?)
   (defonce download-icloud-files (registerPlugin "DownloadiCloudFiles"))
-  (defonce ios-file-container (registerPlugin "FileContainer")))
+  (defonce ios-file-container (registerPlugin "FileContainer"))
+  (defonce file-sync (registerPlugin "FileSync")))
+
 ;; NOTE: both iOS and android share the same FsWatcher API
 (when (is-native-platform?)
   (defonce fs-watcher (registerPlugin "FsWatcher")))

+ 4 - 4
src/main/frontend/modules/shortcut/config.cljs

@@ -37,10 +37,10 @@
    :date-picker/next-day         {:binding "right"
                                   :fn      ui-handler/shortcut-next-day}
 
-   :date-picker/prev-week        {:binding "up"
+   :date-picker/prev-week        {:binding ["up" "ctrl+p"]
                                   :fn      ui-handler/shortcut-prev-week}
 
-   :date-picker/next-week        {:binding "down"
+   :date-picker/next-week        {:binding ["down" "ctrl+n"]
                                   :fn      ui-handler/shortcut-next-week}
 
    :pdf/previous-page            {:binding "alt+p"
@@ -152,10 +152,10 @@
    :editor/cycle-todo              {:binding "mod+enter"
                                     :fn      editor-handler/cycle-todo!}
 
-   :editor/up                      {:binding "up"
+   :editor/up                      {:binding ["up" "ctrl+p"]
                                     :fn      (editor-handler/shortcut-up-down :up)}
 
-   :editor/down                    {:binding "down"
+   :editor/down                    {:binding ["down" "ctrl+n"]
                                     :fn      (editor-handler/shortcut-up-down :down)}
 
    :editor/left                    {:binding "left"

+ 2 - 2
src/main/frontend/modules/shortcut/core.cljs

@@ -66,7 +66,7 @@
          (doseq [k (dh/shortcut-binding id)]
            (try
              (log/debug :shortcut/register-shortcut {:id id :binding k})
-             (.registerShortcut handler (util/keyname id) k)
+             (.registerShortcut handler (util/keyname id) (dh/normalize-user-keyname k))
              (catch js/Object e
                (log/error :shortcut/register-shortcut {:id      id
                                                        :binding k
@@ -81,7 +81,7 @@
   (when-let [handler (get-handler-by-id handler-id)]
     (when-let [ks (dh/shortcut-binding shortcut-id)]
       (doseq [k ks]
-        (.unregisterShortcut ^js handler k)))
+        (.unregisterShortcut ^js handler (dh/normalize-user-keyname k))))
     (shortcut-config/remove-shortcut! handler-id shortcut-id)))
 
 (defn uninstall-shortcut!

+ 17 - 2
src/main/frontend/modules/shortcut/data_helper.cljs

@@ -44,6 +44,17 @@
          shortcut)
        (mapv mod-key)))))
 
+(defn normalize-user-keyname
+  [k]
+  (some-> k
+          (util/safe-lower-case)
+          (str/replace #";+" "semicolon")
+          (str/replace #"=+" "equals")
+          (str/replace #"~+" "dash")
+          (str/replace "[" "open-square-bracket")
+          (str/replace "]" "close-square-bracket")
+          (str/replace "'" "single-quote")))
+
 ;; returns a vector to preserve order
 (defn binding-by-category [name]
   (let [dict (->> (vals @shortcut-config/config)
@@ -150,9 +161,13 @@
     false
     (let [handler-id    (get-group k)
           shortcut-m    (shortcut-map handler-id)
+          parse-shortcut #(try
+                           (KeyboardShortcutHandler/parseStringShortcut %)
+                           (catch js/Error e
+                             (js/console.error "[shortcut/parse-error]" (str % " - " (.-message e)))))
           bindings      (->> (shortcut-binding k)
                              (map mod-key)
-                             (map KeyboardShortcutHandler/parseStringShortcut)
+                             (map parse-shortcut)
                              (map js->clj))
           rest-bindings (->> (map key shortcut-m)
                              (remove #{k})
@@ -160,7 +175,7 @@
                              (filter vector?)
                              (mapcat identity)
                              (map mod-key)
-                             (map KeyboardShortcutHandler/parseStringShortcut)
+                             (map parse-shortcut)
                              (map js->clj))]
 
       (some? (some (fn [b] (some #{b} rest-bindings)) bindings)))))

+ 243 - 16
src/main/frontend/modules/shortcut/dicts.cljc

@@ -1,12 +1,10 @@
 (ns ^:bb-compatible frontend.modules.shortcut.dicts
   "Provides dictionary entries for shortcuts"
   (:require [medley.core :as medley]))
-
 (defn- decorate-namespace [k]
   (let [n (name k)
         ns (namespace k)]
     (keyword (str "command." ns) n)))
-
 (def ^:large-vars/data-var all-default-keyboard-shortcuts
   {:date-picker/complete         "Date picker: Choose selected day"
    :date-picker/prev-day         "Date picker: Select previous day"
@@ -115,7 +113,6 @@
    :editor/toggle-open-blocks       "Toggle open blocks (collapse or expand all blocks)"
    :ui/toggle-cards                 "Toggle cards"
    :git/commit                      "Git commit message"})
-
 (def category
   {:shortcut.category/basics "Basics"
    :shortcut.category/formatting "Formatting"
@@ -125,7 +122,6 @@
    :shortcut.category/block-selection "Block selection (press Esc to quit selection)"
    :shortcut.category/toggle "Toggle"
    :shortcut.category/others "Others"})
-
 (def ^:large-vars/data-var dicts
   {:en (merge
         ;; Dynamically add this ns since command descriptions have to
@@ -133,6 +129,7 @@
         ;; have a namespce
         (medley/map-keys decorate-namespace all-default-keyboard-shortcuts)
         category)
+
    :zh-CN   {:shortcut.category/formatting            "格式化"
              :shortcut.category/basics                "基础操作"
              :shortcut.category/navigating            "移动"
@@ -198,7 +195,6 @@
              :command.editor/open-edit                "编辑选中块"
              :command.editor/delete-selection         "删除选中块"
              :command.editor/toggle-open-blocks       "切换折叠/展开所有块(非编辑状态)"}
-
    :zh-Hant {:command.editor/indent                  "縮進塊標簽"
              :command.editor/outdent                 "取消縮進塊"
              :command.editor/move-block-up           "向上移動塊"
@@ -221,7 +217,7 @@
              :command.ui/toggle-theme                "“在暗色/亮色主題之間切換”"
              :command.ui/toggle-right-sidebar        "啟用/關閉右側欄"
              :command.go/journals                    "跳轉到日記"}
-
+   
    :de      {:shortcut.category/formatting           "Formatierung"
              :command.editor/indent                  "Block einrücken"
              :command.editor/outdent                 "Block ausrücken"
@@ -245,7 +241,6 @@
              :command.git/commit                     "Git Commit-Nachricht"
              :command.editor/select-block-down       "Block unterhalb auswählen"
              :command.editor/select-all-blocks       "Alle Blöcke auswählen"}
-
    :fr      {:shortcut.category/formatting           "Formats"
              :command.editor/indent                  "Indenter un Bloc vers la droite"
              :command.editor/outdent                 "Indenter un Bloc vers la gauche"
@@ -269,7 +264,7 @@
              :command.ui/toggle-theme                "Intervertir le thème foncé/clair"
              :command.ui/toggle-right-sidebar        "Afficher/cacher la barre latérale"
              :command.go/journals                    "Aller au Journal"}
-
+   
    :af      {:shortcut.category/formatting           "Formatering"
              :command.editor/indent                  "Ingekeepte blok oortjie"
              :command.editor/outdent                 "Oningekeepte blok"
@@ -293,7 +288,7 @@
              :command.go/journals                    "Spring na joernale"
              :command.ui/toggle-theme                "Wissel tussen donker/lig temas"
              :command.ui/toggle-right-sidebar        "Wissel regter sybalk"}
-
+   
    :es      {:shortcut.category/formatting            "Formato"
              :shortcut.category/basics                "Básico"
              :shortcut.category/navigating            "Navegación"
@@ -357,7 +352,7 @@
              :command.editor/open-edit                "Editar bloque seleccionado"
              :command.editor/delete-selection         "Eliminar bloques seleccionados"
              :command.editor/toggle-open-blocks       "Alternar bloques abieros, (colapsar o expandir todos)"}
-
+   
    :ru      {:shortcut.category/formatting            "Форматирование"
              :shortcut.category/basics                "Базовые"
              :shortcut.category/navigating            "Навигация"
@@ -421,7 +416,7 @@
              :command.editor/open-edit                "Редактировать выбранный блок"
              :command.editor/delete-selection         "Удалить выбранные блоки"
              :command.editor/toggle-open-blocks       "Переключить открытые блоки (свернуть или развернуть все)"}
-
+   
    :nb-NO   {:shortcut.category/formatting            "Formatering"
              :shortcut.category/basics                "Basis"
              :shortcut.category/navigating            "Navigasjon"
@@ -486,7 +481,7 @@
              :command.editor/open-edit                "Rediger valgt blokk"
              :command.editor/delete-selection         "Slett valgte blokker"
              :command.editor/toggle-open-blocks       "Veksle åpne blokker (slå sammen eller utvid alle blokker)"}
-
+   
    :pt-PT   {:shortcut.category/formatting            "Formatação"
              :shortcut.category/basics                "Básico"
              :shortcut.category/navigating            "Navegação"
@@ -550,7 +545,7 @@
              :command.editor/open-edit                "Editar bloco selecionado"
              :command.editor/delete-selection         "Eliminar blocos selecionados"
              :command.editor/toggle-open-blocks       "Alternar blocos abertos (colapsar ou expandir todos)"}
-
+   
    :pt-BR   {:shortcut.category/formatting            "Formatação"
              :shortcut.category/basics                "Básico"
              :shortcut.category/navigating            "Navegação"
@@ -664,7 +659,7 @@
              :command.misc/copy                       "Copiar (copiar seleção ou referência do bloco)"
              :command.ui/goto-plugins                 "Ir para o painel de plugins"
              :command.ui/open-new-window              "Abra uma nova janela"}
-
+   
    :ja      {:shortcut.category/formatting                "フォーマット"
              :shortcut.category/basics                "基本操作"
              :shortcut.category/navigating            "ナビゲーション"
@@ -779,5 +774,237 @@
              :command.editor/strike-through                   "打ち消し線"
              :command.misc/copy                               "コピー"
              :command.ui/goto-plugins                         "プラグインへ"
-             :command.ui/select-theme-color                   "利用可能なテーマ色を選択"
-             }})
+             :command.ui/select-theme-color                   "利用可能なテーマ色を選択"}
+
+   :it      {:command.date-picker/complete         "Selettore data: scegli il giorno selezionato"
+             :command.date-picker/prev-day         "Selettore data: Seleziona il giorno precedente"
+             :command.date-picker/next-day         "Selettore data: Seleziona il giorno successivo"
+             :command.date-picker/prev-week        "Selettore data: Seleziona la settimana precedente"
+             :command.date-picker/next-week        "Selettore data: Seleziona la settimana successiva"
+             :command.pdf/previous-page            "Pagina precedente del pdf corrente"
+             :command.pdf/next-page                "Pagina successiva del pdf corrente"
+             :command.auto-complete/complete       "Auto completamento: Scegli l'oggetto selezionato"
+             :command.auto-complete/prev           "Auto completamento: Seleziona l'oggetto precedente"
+             :command.auto-complete/next           "Auto completamento: Seleziona l'oggetto successivo"
+             :command.auto-complete/shift-complete "Auto completamento: Apri l'oggetto selezionato nella barra laterale"
+             :command.auto-complete/open-link      "Auto completamento: Apri l'oggetto selezionato nel browser"
+             :command.cards/toggle-answers         "Carte: mostra/nascondi risposte/chiusure"
+             :command.cards/next-card              "Carte: prossima carta"
+             :command.cards/forgotten              "Carte: dimenticato"
+             :command.cards/remembered             "Carte: ricordato"
+             :command.cards/recall                 "Carte: ci ho messo un pò a ricordarlo"
+             :command.editor/escape-editing        "Esci dalla modifica"
+             :command.editor/backspace             "Tasto Backspace / Cancella all'indietro"
+             :command.editor/delete                "Tasto Delete / Cancella avanti"
+             :command.editor/new-block             "Crea un nuovo blocco"
+             :command.editor/new-line              "Nuova riga accapo nel blocco attuale"
+             :command.editor/follow-link           "Segui il link sotto al cursore"
+             :command.editor/open-link-in-sidebar  "Apri il link nella barra laterale"
+             :command.editor/bold                  "Grassetto"
+             :command.editor/italics               "Corsivo"
+             :command.editor/highlight             "Evidenzia"
+             :command.editor/strike-through        "Barrato"
+             :command.editor/clear-block           "Elimina l'intero contenuto del blocco"
+             :command.editor/kill-line-before      "Cancella la riga prima della posizione del cursore"
+             :command.editor/kill-line-after       "Cancella la riga dopo la posizione del cursore"
+             :command.editor/beginning-of-block    "Muovi il cursore all'inizio di un blocco"
+             :command.editor/end-of-block          "Muovi il cursore alla fine di un blocco"
+             :command.editor/forward-word          "Muovi il cursore in avanti di una parola"
+             :command.editor/backward-word         "Muovi il cursore all'indietro di una parola"
+             :command.editor/forward-kill-word     "Elimina una parola in avanti"
+             :command.editor/backward-kill-word    "Elimina una parola all'indietro"
+             :command.editor/replace-block-reference-at-point "Sostituisci il riferimento di blocco con il suo contenuto al punto"
+             :command.editor/paste-text-in-one-block-at-point "Incolla testo in un blocco al punto"
+             :command.editor/insert-youtube-timestamp         "Inserisci marca temporale di youtube"
+             :command.editor/cycle-todo              "Cicla lo stato TODO dell'elemento corrente"
+             :command.editor/up                      "Muovi il cursore sopra / Seleziona sopra"
+             :command.editor/down                    "Muovi il cursore sotto / Seleziona sotto"
+             :command.editor/left                    "Muovi il cursore a sinistra / Apri il blocco selezionato all'inizio"
+             :command.editor/right                   "Muovi il cursore a destra / Apri il blocco selezionato all'inizio"
+             :command.editor/select-up               "Seleziona il contenuto sopra"
+             :command.editor/select-down             "Seleziona il contenuto sotto"
+             :command.editor/move-block-up           "Muovi il blocco sopra"
+             :command.editor/move-block-down         "Muovi il blocco sotto"
+             :command.editor/open-edit               "Modifica il blocco selezionato"
+             :command.editor/select-block-up         "Seleziona blocco sopra"
+             :command.editor/select-block-down       "Seleziona blocco sotto"
+             :command.editor/delete-selection        "Elimina i blocchi selezionati"
+             :command.editor/expand-block-children   "Espandi"
+             :command.editor/collapse-block-children "Collassa"
+             :command.editor/indent                  "Rientra blocco"
+             :command.editor/outdent                 "Annulla il rientro blocco"
+             :command.editor/copy                    "Copia (copia una selezione o un riferimento di blocco)"
+             :command.editor/cut                     "Taglia"
+             :command.editor/undo                    "Annulla"
+             :command.editor/redo                    "Rifai"
+             :command.editor/insert-link             "Link HTML"
+             :command.editor/select-all-blocks       "Seleziona tutti i blocchi"
+             :command.editor/zoom-in                 "Ingrandisci blocco di modifica / Avanti altrimenti"
+             :command.editor/zoom-out                "Rimpicciolisci il blocco di modifica / Indietro altrimenti"
+             :command.ui/toggle-brackets             "Selezionare se visualizzare le parentesi"
+             :command.go/search-in-page              "Cerca nella pagina attuale"
+             :command.go/search                      "Ricerca testo completo"
+             :command.go/journals                    "Vai ai diari"
+             :command.go/backward                    "Indietro"
+             :command.go/forward                     "Avanti"
+             :command.search/re-index                "Ricostruisci indice di ricerca"
+             :command.sidebar/open-today-page        "Apri la pagina di oggi nella barra laterale destra"
+             :command.sidebar/clear                  "Pulisci tutto nella barra laterale destra"
+             :command.misc/copy                      "mod+c"
+             :command.command-palette/toggle         "Attiva/disattiva tavolozza comandi"
+             :command.graph/open                     "Seleziona il diagramma da aprire"
+             :command.graph/remove                   "Rimuovi un diagramma"
+             :command.graph/add                      "Aggiungi un diagramma"
+             :command.graph/save                     "Salva il diagramma corrente su disco"
+             :command.command/run                    "Esegui comando git"
+             :command.go/home                        "Vai all'inizio"
+             :command.go/all-pages                   "Vai a tutte le pagine"
+             :command.go/graph-view                  "Vai alla visualizzazione diagramma"
+             :command.go/keyboard-shortcuts          "Vai alle scorciatoie da tastiera"
+             :command.go/tomorrow                    "Vai a domani"
+             :command.go/next-journal                "Vai al prossimo diario"
+             :command.go/prev-journal                "Vai al diario precedente"
+             :command.go/flashcards                  "Attiva/disattiva carte flash"
+             :command.ui/toggle-document-mode        "Attiva/disattiva modalità documento"
+             :command.ui/toggle-settings             "Attiva/disattiva impostazioni"
+             :command.ui/toggle-right-sidebar        "Attiva/disattiva barra laterale destra"
+             :command.ui/toggle-left-sidebar         "Attiva/disattiva barra laterale sinistra"
+             :command.ui/toggle-help                 "Attiva/disattiva aiuto"
+             :command.ui/toggle-theme                "Passa dal tema scuro a quello chiaro"
+             :command.ui/toggle-contents             "Attiva/disattiva i contenuti nella barra laterale"
+             :command.ui/open-new-window             "Apri un'altra finestra"
+             :command.command/toggle-favorite        "Aggiungi a/rimuovi dai preferiti"
+             :command.editor/open-file-in-default-app "Apri file nell'app predefinita"
+             :command.editor/open-file-in-directory   "Apri file nella directory principale"
+             :command.editor/copy-current-file        "Copia file corrente"
+             :command.ui/toggle-wide-mode             "Attiva/disattiva modalità ampia"
+             :command.ui/select-theme-color           "Seleziona i colori del tema disponibili"
+             :command.ui/goto-plugins                 "Vai alla dashboard dei plugin"
+             :command.editor/toggle-open-blocks       "Attiva/disattiva i blocchi aperti (comprimi o espandi tutti i blocchi)"
+             :command.ui/toggle-cards                 "Attiva/disattiva le carte"
+             :command.git/commit                      "Git messaggio di commit"
+             :shortcut.category/basics                "Nozioni di base"
+             :shortcut.category/formatting            "Formattazione"
+             :shortcut.category/navigating            "Navigazione"
+             :shortcut.category/block-editing         "Modiifica blocco generale"
+             :shortcut.category/block-command-editing "Modifica comandi blocco"
+             :shortcut.category/block-selection       "Selezione blocco (premi Esc per uscire dalla selezione)"
+             :shortcut.category/toggle                "Attiva/disattiva"
+             :shortcut.category/others                "Altri"
+
+   }
+   :tr      {:shortcut.category/basics "Temel bilgiler"
+             :shortcut.category/formatting "Biçimlendirme"
+             :shortcut.category/navigating "Gezinme"
+             :shortcut.category/block-editing "Genel blok düzenleme"
+             :shortcut.category/block-command-editing "Blok düzenleme komutuları"
+             :shortcut.category/block-selection "Blok seçimi (seçimden çıkmak için Esc tuşuna basın)"
+             :shortcut.category/toggle "Aç/Kapat"
+             :shortcut.category/others "Diğer"
+             :command.date-picker/complete         "Tarih seçici: Seçilen günü seç"
+             :command.date-picker/prev-day         "Tarih seçici: Önceki günü seç"
+             :command.date-picker/next-day         "Tarih seçici: Sonraki günü seç"
+             :command.date-picker/prev-week        "Tarih seçici: Önceki haftayı seç"
+             :command.date-picker/next-week        "Tarih seçici: Sonraki haftayı seç"
+             :command.pdf/previous-page            "Geçerli pdf belgesinin önceki sayfası"
+             :command.pdf/next-page                "Geçerli pdf belgesinin sonraki sayfası"
+             :command.auto-complete/complete       "Otomatik tamamlama: Seçili öğeyi seç"
+             :command.auto-complete/prev           "Otomatik tamamlama: Önceki öğeyi seç"
+             :command.auto-complete/next           "Otomatik tamamlama: Sonraki öğeyi seç"
+             :command.auto-complete/shift-complete "Otomatik tamamlama: Seçili öğeyi kenar çubuğunda aç"
+             :command.auto-complete/open-link      "Otomatik tamamlama: Seçili öğeyi tarayıcıda aç"
+             :command.cards/toggle-answers         "Kartlar: cevapları ve cümle tamamlamayı göster/gizle"
+             :command.cards/next-card              "Kartlar: sonraki kart"
+             :command.cards/forgotten              "Kartlar: unutuldu"
+             :command.cards/remembered             "Kartlar: hatırlandı"
+             :command.cards/recall                 "Kartlar: hatırlamak biraz zaman aldı"
+             :command.editor/escape-editing        "Düzenlemeden çık"
+             :command.editor/backspace             "Geri tuşu (Backspace) / Geriye doğru sil"
+             :command.editor/delete                "Silme tuşu (Delete) / İleriye doğru sil"
+             :command.editor/new-block             "Yeni blok oluştur"
+             :command.editor/new-line              "Geçerli blokta yeni satır"
+             :command.editor/follow-link           "İmlecin altındaki bağlantıyı takip et"
+             :command.editor/open-link-in-sidebar  "Bağlantıyı kenar çubuğunda aç"
+             :command.editor/bold                  "Kalın"
+             :command.editor/italics               "İtalik"
+             :command.editor/highlight             "Vurgulu"
+             :command.editor/strike-through        "Üstü çizili"
+             :command.editor/clear-block           "Tüm blok içeriğini sil"
+             :command.editor/kill-line-before      "İmleç konumundan önceki satırı sil"
+             :command.editor/kill-line-after       "İmleç konumundan sonraki satırı sil"
+             :command.editor/beginning-of-block    "İmleci bir bloğun başına taşı"
+             :command.editor/end-of-block          "İmleci bir bloğun sonuna taşı"
+             :command.editor/forward-word          "İmleci bir kelime ileri götür"
+             :command.editor/backward-word         "İmleci bir kelime geri götür"
+             :command.editor/forward-kill-word     "İleriye doğru bir kelimeyi sil"
+             :command.editor/backward-kill-word    "Geriye doğru bir kelimeyi sil"
+             :command.editor/replace-block-reference-at-point "Blok referansını bu konumdaki içeriğiyle değiştirin"
+             :command.editor/paste-text-in-one-block-at-point "İmleç konumunda metin olarak yapıştırın"
+             :command.editor/insert-youtube-timestamp         "Youtube zaman damgası ekle"
+             :command.editor/cycle-todo              "Geçerli öğenin TODO durumunu değiştir"
+             :command.editor/up                      "İmleci yukarı taşı / Yukarıyı seç"
+             :command.editor/down                    "İmleci aşağı taşı / Aşağı seç"
+             :command.editor/left                    "İmleci sola hareket ettir / Seçili bloğu aç ve başına git"
+             :command.editor/right                   "İmleci sağa hareket ettir / Seçili bloğu aç ve sonuna git"
+             :command.editor/select-up               "Yukarıdaki içeriği seçin"
+             :command.editor/select-down             "Aşağıdaki içeriği seçin"
+             :command.editor/move-block-up           "Bloğu yukarı taşı"
+             :command.editor/move-block-down         "Bloğu aşağı taşı"
+             :command.editor/open-edit               "Seçili bloğu düzenle"
+             :command.editor/select-block-up         "Yukarıdaki bloğu seçin"
+             :command.editor/select-block-down       "Aşağıdaki bloğu seçin"
+             :command.editor/delete-selection        "Seçili blokları sil"
+             :command.editor/expand-block-children   "Genişlet"
+             :command.editor/collapse-block-children "Daralt"
+             :command.editor/indent                  "Bloğu girintile"
+             :command.editor/outdent                 "Blok girintisini azalt"
+             :command.editor/copy                    "Kopyala (seçimi veya blok referansını kopyalar)"
+             :command.editor/cut                     "Kes"
+             :command.editor/undo                    "Geri al"
+             :command.editor/redo                    "Tinele"
+             :command.editor/insert-link             "HTML Bağlantısı"
+             :command.editor/select-all-blocks       "Tüm blokları seç"
+             :command.editor/zoom-in                 "Düzenlenen bloğu yakınlaştır / Aksi takdirde ileri git"
+             :command.editor/zoom-out                "Düzenlenen bloğu uzaklaştır / Aksi takdirde geri git"
+             :command.ui/toggle-brackets             "Köşeli ayraçların görüntülenip görüntülenmeyeceğini değiştir"
+             :command.go/search-in-page              "Geçerli sayfada ara"
+             :command.go/search                      "Tam metin araması"
+             :command.go/journals                    "Günlüğe git"
+             :command.go/backward                    "Geriye git"
+             :command.go/forward                     "İleriye git"
+             :command.search/re-index                "Arama dizinini yeniden oluştur"
+             :command.sidebar/open-today-page        "Sağ kenar çubuğunda bugünün sayfasını açın"
+             :command.sidebar/clear                  "Sağ kenar çubuğundaki herşeyi temizle"
+             :command.misc/copy                      "mod+c"
+             :command.command-palette/toggle         "Komut paletini aç"
+             :command.graph/open                     "Açılacak grafiği seçin"
+             :command.graph/remove                   "Bir grafiği kaldır"
+             :command.graph/add                      "Grafik ekle"
+             :command.graph/save                     "Mevcut grafiği diske kaydet"
+             :command.command/run                    "Git komutunu çalıştır"
+             :command.go/home                        "Ana sayfaya git"
+             :command.go/all-pages                   "Bütün sayfalara git"
+             :command.go/graph-view                  "Grafik görünümüne git"
+             :command.go/keyboard-shortcuts          "Klavye kısayollarına git"
+             :command.go/tomorrow                    "Yarının günlüğüne git"
+             :command.go/next-journal                "Sonraki günlüğe git"
+             :command.go/prev-journal                "Önceki günlüğe git"
+             :command.go/flashcards                  "Bilgi kartlarına git"
+             :command.ui/toggle-document-mode        "Belge modunu aç/kapat"
+             :command.ui/toggle-settings             "Ayarları aç/kapat"
+             :command.ui/toggle-right-sidebar        "Sağ kenar çubuğunu aç/kapat"
+             :command.ui/toggle-left-sidebar         "Sol kenar çubuğunu aç/kapat"
+             :command.ui/toggle-help                 "Yardımı aç/kapat"
+             :command.ui/toggle-theme                "Koyu ve açık tema arasında geçiş yap"
+             :command.ui/toggle-contents             "Kenar çubuğundaki içeriği aç/kapat"
+             :command.ui/open-new-window             "Başka bir pencere aç"
+             :command.command/toggle-favorite        "Sık kullanılanlara ekle/çıkar"
+             :command.editor/open-file-in-default-app "Dosyayı varsayılan uygulamada aç"
+             :command.editor/open-file-in-directory   "Dosyayı üst dizinde aç"
+             :command.editor/copy-current-file        "Geçerli dosyayı kopyala"
+             :command.ui/toggle-wide-mode             "Geniş modu aç/kapat"
+             :command.ui/select-theme-color           "Kullanılabilir tema renklerini seçin"
+             :command.ui/goto-plugins                 "Eklentiler panosuna git"
+             :command.editor/toggle-open-blocks       "Açık blokları kapat/aç (tüm blokları daralt veya genişlet)"
+             :command.ui/toggle-cards                 "Kartları aç/kapat"
+             :command.git/commit                      "Git commit mesajı"}})

+ 4 - 2
src/test/frontend/modules/outliner/core_test.cljs

@@ -429,7 +429,8 @@
               (let [total (get-blocks-count)]
                 (is (= total (+ c1 @*random-count)))))))))))
 
-(deftest ^:long random-move-up-down
+;; TODO: Enable when not failing as intermittently
+#_(deftest ^:long random-move-up-down
   (testing "Random move up down"
     (transact-random-tree!)
     (let [c1 (get-blocks-count)
@@ -445,7 +446,8 @@
             (let [total (get-blocks-count)]
               (is (= total (+ c1 @*random-count))))))))))
 
-(deftest ^:long random-indent-outdent
+;; TODO: Enable when not failing as intermittently
+#_(deftest ^:long random-indent-outdent
   (testing "Random indent and outdent"
     (transact-random-tree!)
     (let [c1 (get-blocks-count)

+ 14 - 0
templates/dummy-notes-tr.md

@@ -0,0 +1,14 @@
+---
+title: Nasıl not alınır?
+---
+
+- Merhaba, ben bir bloğum!
+:PROPERTIES:
+:id: 5f713e91-8a3c-4b04-a33a-c39482428e2d
+:END:
+    - Ben bir alt bloğum!
+    - Ben başka bir alt bloğum!
+- Hey, Ben başka bir bloğum!
+:PROPERTIES:
+:id: 5f713ea8-8cba-403d-ac00-9964b1ec7190
+:END:

+ 26 - 0
templates/tutorial-tr.md

@@ -0,0 +1,26 @@
+## Merhaba, Logseq'e hoş geldiniz!
+- Logseq, _bilgi_ yönetimi ve işbirliği için _gizlilik öncelikli_, [açık kaynaklı](https://github.com/logseq/logseq) bir platformdur.
+- Bu, Logseq'in nasıl kullanılacağına dair 3 dakikalık bir eğitimdir. Başlayalım!
+- İşte yararlı olabilecek bazı ipuçları.
+#+BEGIN_TIP
+Herhangi bir bloğu düzenlemek için tıklayın.
+Yeni bir blok oluşturmak için `Enter` tuşuna basın.
+Yeni bir satır oluşturmak için `Shift+Enter` tuşuna basın.
+Tüm komutları göstermek için `/` tuşuna basın.
+#+END_TIP
+- 1. [[Nasıl not alınır?]] adında bir sayfa oluşturalım. O sayfaya gitmek için üzerine tıklayabilir veya sağ kenar çubuğunda açmak için `Shift+Tıkla` yapabilirsiniz! Şimdi hem _Bağlantılı Referanslar_ hem de _Bağlantısız Referanslar_ görmelisiniz.
+- 2. [[Nasıl not alınır?]] sayfasındaki bazı bloklara referans verelim. Sağ kenar çubuğunda açmak için herhangi bir blok referansını 'Shift+Tıkla' yapabilirsiniz.  Sağ kenar çubuğunda bazı değişiklikler yapmayı deneyin, referans verilen bloklar da değişecek!
+    - ((5f713e91-8a3c-4b04-a33a-c39482428e2d)) : Bu bir blok referansıdır.
+    - ((5f713ea8-8cba-403d-ac00-9964b1ec7190)) : Bu başka bir blok referansıdır.
+- 3. Etiketleri destekliyor musunuz?
+    - Tabii ki, bu bir #test etiketi.
+- 4. Yapılacak işler ve öncelikler (todo/doing/done) gibi görevleri destekliyor musunuz?
+    - Evet, `/` tuşuna basın ve favori yapılacaklar anahtar kelimenizi veya önceliğinizi (A/B/C) seçin.
+    - NOW [#A] "Nasıl not alınır?" adlı bir sayfa yarat.
+    - LATER [#A] Not almak ve hayatınızı düzenlemek için Logseq'i nasıl kullanacağınıza dair [:a {:href "https://twitter.com/shuomi3" :target "_blank"} "@shuomi3"] kullanıcısının bu harika videosunu izleyin!
+    {{youtube https://www.youtube.com/watch?v=BhHfF0P9A80&ab_channel=ShuOmi}}
+
+    - DONE Bir sayfa yarat
+    - CANCELED [#C] 1000'den fazla blok içeren bir sayfa yazın
+- Bu kadar! Şimdi bazı notları içe aktarmak için daha fazla madde işareti oluşturabilir veya yerel bir dizin açabilirsiniz!
+- Masaüstü uygulamamızı https://github.com/logseq/logseq/releases adresinden indirebilirsiniz.