├── src ├── i18n │ ├── en-US.json │ ├── ja-JP.json │ ├── zh-CN.json │ ├── zh-TW.json │ └── index.d.ts ├── assets │ ├── 16.png │ ├── 32.png │ ├── 48.png │ ├── 128.png │ └── logo.png ├── index.css ├── scope │ ├── options │ │ ├── index.css │ │ ├── config │ │ │ └── note.ts │ │ ├── index.tsx │ │ └── App.tsx │ └── popup │ │ ├── index.tsx │ │ └── components │ │ └── Syncwise.tsx ├── constants │ ├── env.ts │ ├── config.ts │ ├── twitter.ts │ ├── i18n.ts │ └── langs.ts ├── App.tsx ├── parser │ └── twitter │ │ ├── index.ts │ │ └── bookmark.ts ├── content-script │ ├── inject-script.ts │ ├── handlers │ │ └── twitter.ts │ ├── utils │ │ ├── twitter-scroll.ts │ │ ├── store.ts │ │ └── hijack-xhr.ts │ ├── plugins │ │ └── collect-twitter-bookmarks.ts │ └── main.ts ├── types │ ├── pkm.d.ts │ ├── twitter │ │ ├── parse.d.ts │ │ └── bookmark.d.ts │ └── logseq │ │ └── block.ts ├── vite-env.d.ts ├── main.tsx ├── config │ ├── logseq.ts │ └── config.ts ├── pkms │ ├── logseq │ │ ├── error.ts │ │ └── client.ts │ └── obsidian │ │ └── client.ts ├── handler │ └── output │ │ ├── background │ │ └── index.ts │ │ ├── logseq │ │ └── index.ts │ │ └── obsidian │ │ └── index.ts ├── background.ts └── utils.ts ├── release ├── src.zip ├── chrome.zip └── firefox.zip ├── docs ├── logseq-setting.jpg ├── obsidian-config.jpg ├── obsidian-plugin.jpg ├── logseq-server-start.jpg ├── logseq-token-setting.jpg ├── check-logseq-connection.jpg ├── obsidian-plugin-config.jpg └── syncwise-collect-start.jpg ├── postcss.config.js ├── tailwind.config.js ├── .prettierrc ├── tsconfig.node.json ├── .gitignore ├── index.html ├── options.html ├── manifest.json ├── tsconfig.json ├── README.md ├── vite.config.ts └── package.json /src/i18n/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "example": "example" 3 | } 4 | -------------------------------------------------------------------------------- /src/i18n/ja-JP.json: -------------------------------------------------------------------------------- 1 | { 2 | "example": "example" 3 | } 4 | -------------------------------------------------------------------------------- /src/i18n/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "example": "example" 3 | } 4 | -------------------------------------------------------------------------------- /src/i18n/zh-TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "example": "example" 3 | } 4 | -------------------------------------------------------------------------------- /release/src.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/release/src.zip -------------------------------------------------------------------------------- /src/assets/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/src/assets/16.png -------------------------------------------------------------------------------- /src/assets/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/src/assets/32.png -------------------------------------------------------------------------------- /src/assets/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/src/assets/48.png -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /release/chrome.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/release/chrome.zip -------------------------------------------------------------------------------- /release/firefox.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/release/firefox.zip -------------------------------------------------------------------------------- /src/assets/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/src/assets/128.png -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /src/scope/options/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /docs/logseq-setting.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/docs/logseq-setting.jpg -------------------------------------------------------------------------------- /docs/obsidian-config.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/docs/obsidian-config.jpg -------------------------------------------------------------------------------- /docs/obsidian-plugin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/docs/obsidian-plugin.jpg -------------------------------------------------------------------------------- /src/constants/env.ts: -------------------------------------------------------------------------------- 1 | export function isProduction() { 2 | return !import.meta.env.DEV 3 | } 4 | -------------------------------------------------------------------------------- /docs/logseq-server-start.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/docs/logseq-server-start.jpg -------------------------------------------------------------------------------- /docs/logseq-token-setting.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/docs/logseq-token-setting.jpg -------------------------------------------------------------------------------- /docs/check-logseq-connection.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/docs/check-logseq-connection.jpg -------------------------------------------------------------------------------- /docs/obsidian-plugin-config.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/docs/obsidian-plugin-config.jpg -------------------------------------------------------------------------------- /docs/syncwise-collect-start.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slarkvan/syncwise/HEAD/docs/syncwise-collect-start.jpg -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import PopupPage from '@/scope/popup' 2 | 3 | export function App() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /src/i18n/index.d.ts: -------------------------------------------------------------------------------- 1 | export type TranslateType = { 2 | "example": { 3 | value: "example" 4 | params: [key: "example"] 5 | } 6 | }; -------------------------------------------------------------------------------- /src/parser/twitter/index.ts: -------------------------------------------------------------------------------- 1 | // TODO: like or other 2 | export { parseBookmarkResponse, beautifyLogseqText, beautifyObsidianText } from './bookmark' 3 | -------------------------------------------------------------------------------- /src/content-script/inject-script.ts: -------------------------------------------------------------------------------- 1 | import { hijackXHR } from '@/content-script/utils/hijack-xhr' 2 | import { twitterScroll } from '@/content-script/utils/twitter-scroll' 3 | 4 | hijackXHR() 5 | twitterScroll() 6 | -------------------------------------------------------------------------------- /src/types/pkm.d.ts: -------------------------------------------------------------------------------- 1 | export enum NoteSyncTarget { 2 | Logseq = 'logseq', 3 | Obsidian = 'obsidian', 4 | } 5 | 6 | export enum NoteSyncLocationType { 7 | Journal = 'Journal', 8 | CustomPage = 'customPage', 9 | } 10 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | VITE_GITHUB_CLIENT_SECRET: string 5 | } 6 | 7 | declare module '*?script&module' { 8 | const src: string 9 | export default src 10 | } 11 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: 'media', 4 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [], 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "trailingComma": "es5", 6 | "tabWidth": 4, 7 | "useTabs": false, 8 | "quoteProps": "consistent", 9 | "bracketSpacing": true, 10 | "printWidth": 120 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts", "manifest.json"] 10 | } 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { App } from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /src/content-script/handlers/twitter.ts: -------------------------------------------------------------------------------- 1 | import { bookmarksStore, syncedBookmarksStore } from '../utils/store' 2 | 3 | export const getUnsyncedTwitterBookmarks = (cb: (d: { data: TweetBookmarkParsedItem[] }) => void) => { 4 | const rawList: any = bookmarksStore.load() 5 | const syncedList: any = syncedBookmarksStore.load() ?? [] 6 | const list = rawList?.filter((item: any) => !syncedList.includes(item.id)) 7 | cb({ data: list }) 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | dist-firefox 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | # release/ 27 | *.pem 28 | *.crx 29 | debug/ 30 | dev/ 31 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Syncwise 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Options 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/scope/options/config/note.ts: -------------------------------------------------------------------------------- 1 | import { NoteSyncTarget } from '@/types/pkm.d' 2 | 3 | export const NOTE_TEXT = { 4 | [NoteSyncTarget.Obsidian]: { 5 | title: 'Obsidian', 6 | desc: 'Markdown knowledge base with advanced linking and customization features.', 7 | }, 8 | [NoteSyncTarget.Logseq]: { 9 | title: 'Logseq', 10 | desc: 'Open-source, markdown-based note-taking tool emphasizing interconnected knowledge.', 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /src/scope/options/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { GeistProvider, CssBaseline } from '@geist-ui/core' 4 | import { App } from './App.tsx' 5 | import './index.css' 6 | 7 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 8 | 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /src/content-script/utils/twitter-scroll.ts: -------------------------------------------------------------------------------- 1 | import { TASK_TWITTER_BOOKMARKS_SCROLL_FOR_COLLECTION } from '@/constants/twitter' 2 | import { scrollUntilLastBookmark } from '@/content-script/plugins/collect-twitter-bookmarks' 3 | 4 | export function twitterScroll() { 5 | window.onload = function () { 6 | const value = localStorage.getItem(TASK_TWITTER_BOOKMARKS_SCROLL_FOR_COLLECTION) 7 | if (value === 'init') { 8 | scrollUntilLastBookmark() 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/twitter/parse.d.ts: -------------------------------------------------------------------------------- 1 | type UrlItem = { label: string; value: string } 2 | 3 | interface TweetBookmarkParsedItem { 4 | id: string // 去重专用 5 | url: string 6 | rest_id: string 7 | screen_name: string // 这个不能变 8 | nickname: string // 这个可以变 9 | full_text: string | undefined // Assuming 'legacy?.full_text' can be undefined 10 | // all_text: string | undefined; // Assuming 'note_tweet?.note_tweet_results?.result?.text' can be undefined 11 | images: string[] // Assuming 'images' is an array of strings (URLs) 12 | urls: UrlItem[] 13 | } 14 | -------------------------------------------------------------------------------- /src/config/logseq.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { LogseqSyncConfig } from './config' 3 | 4 | export const getLogseqSyncConfig = async (): Promise => { 5 | const data = await Browser.storage.local.get('logseq') 6 | const { host, port, token, pageType, pageName } = data?.logseq ?? {} 7 | return { 8 | host, 9 | port, 10 | token, 11 | pageType, 12 | pageName, 13 | } 14 | } 15 | 16 | export const saveLogseqSyncConfig = async (updates: Partial) => { 17 | await Browser.storage.local.set(updates) 18 | } 19 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "syncwise", 4 | "version": "0.0.1", 5 | "action": { "default_popup": "index.html" }, 6 | "options_ui": { 7 | "page": "options.html", 8 | "open_in_tab": true 9 | }, 10 | "permissions": ["storage", "activeTab","tabs"], 11 | "icons": { 12 | "16": "src/assets/16.png", 13 | "32": "src/assets/32.png", 14 | "48": "src/assets/48.png", 15 | "128": "src/assets/128.png" 16 | }, 17 | "content_scripts": [ 18 | { 19 | "js": ["src/content-script/main.ts"], 20 | "matches": ["https://twitter.com/*"], 21 | "run_at": "document_start" 22 | } 23 | ], 24 | "background": { 25 | "service_worker": "src/background.ts" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | // "module": "ESNext", 7 | "module": "esnext", 8 | "skipLibCheck": true, 9 | "baseUrl": "./", 10 | "paths": { 11 | "@/*": ["./src/*"], 12 | }, 13 | 14 | /* Bundler mode */ 15 | "moduleResolution": "bundler", 16 | "allowImportingTsExtensions": true, 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | 22 | /* Linting */ 23 | "strict": true, 24 | // "noUnusedLocals": true, 25 | // "noUnusedParameters": true, 26 | // "noFallthroughCasesInSwitch": true 27 | }, 28 | "include": ["src", "debug/dev"], 29 | "references": [{ "path": "./tsconfig.node.json" }] 30 | } 31 | -------------------------------------------------------------------------------- /src/constants/config.ts: -------------------------------------------------------------------------------- 1 | import { Lang } from './langs' 2 | import type { Storage } from 'webextension-polyfill' 3 | import Browser from 'webextension-polyfill' 4 | 5 | export interface Config { 6 | language?: Lang // default: Display language 7 | hideDiscoverMore?: boolean // default: true 8 | hideHomeTabs?: boolean // default: true 9 | hideRightSidebar?: boolean // default: false 10 | hideTimelineExplore?: boolean // default: true 11 | hideOther?: boolean // default: true 12 | blockScamTweets?: boolean // default: true 13 | } 14 | 15 | export async function setConfig(config: Partial) { 16 | console.log('本地存储 config', config) 17 | await Browser.storage.sync.set(config) 18 | } 19 | 20 | export async function onChange(cb: (changes: Storage.StorageAreaOnChangedChangesType) => void) { 21 | Browser.storage.sync.onChanged.addListener(cb) 22 | return async () => Browser.storage.sync.onChanged.removeListener(cb) 23 | } 24 | -------------------------------------------------------------------------------- /src/pkms/logseq/error.ts: -------------------------------------------------------------------------------- 1 | import { LogseqResponseType } from './client' 2 | 3 | export type LogseqClientError = LogseqResponseType 4 | 5 | export const TokenNotCorrect: LogseqClientError = { 6 | msg: 'Token not correct, Please checking your Logseq Authorization Setting.', 7 | status: 401, 8 | response: null, 9 | } 10 | 11 | export const LogseqVersionIsLower: LogseqClientError = { 12 | msg: 'Logseq version is lower, Please upgrade your Logseq version.\nhttps://logseq.com/downloads', 13 | status: 500, 14 | response: null, 15 | } 16 | 17 | export const CannotConnectWithLogseq: LogseqClientError = { 18 | // port 错误 or Logseq not open 19 | msg: 'Cannot connect to Logseq, Please open your Logseq and config well.', 20 | status: 500, 21 | response: null, 22 | } 23 | 24 | export const NoSearchingResult: LogseqClientError = { 25 | msg: 'Not found.', 26 | status: 404, 27 | response: null, 28 | } 29 | 30 | export const UnknownIssues: LogseqClientError = { 31 | msg: 'Unknow issues, may you can connect with author.', 32 | status: 500, 33 | response: null, 34 | } 35 | -------------------------------------------------------------------------------- /src/handler/output/background/index.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { MESSAGE_GET_PHASE_SPECIFIC_RAW_DATA, MESSAGE_ORIGIN_BACKGROUND } from '@/constants/twitter' 3 | 4 | async function sendMessageToContentScript(tabId: number, message: any) { 5 | try { 6 | const response = await Browser.tabs.sendMessage(tabId, message) 7 | console.log('Response:', response) 8 | return response 9 | } catch (error: any) { 10 | // TODO: error handling 11 | throw new Error(error.message) 12 | } 13 | } 14 | 15 | export async function getUnSyncedTwitterBookmarks(): Promise { 16 | const tabs = await Browser.tabs.query({ 17 | active: true, 18 | currentWindow: true, 19 | }) 20 | const tabId = tabs?.[0]?.id 21 | if (!tabId) { 22 | console.log('no active tab') 23 | return 24 | } 25 | const result = await sendMessageToContentScript(tabId, { 26 | from: MESSAGE_ORIGIN_BACKGROUND, 27 | type: MESSAGE_GET_PHASE_SPECIFIC_RAW_DATA, 28 | }) 29 | return result?.data ?? [] 30 | } 31 | -------------------------------------------------------------------------------- /src/constants/twitter.ts: -------------------------------------------------------------------------------- 1 | export const TWITTER_BOOKMARKS_XHR_HIJACK = 'syncwise/twitter_bookmarks_xhr_hijack' 2 | 3 | export const TASK_TWITTER_BOOKMARKS_SCROLL_FOR_COLLECTION = 'syncwise/task_twitter_bookmarks_scroll_for_collection' 4 | 5 | // localStorage key 6 | export const KEY_TWITTER_BOOKMARKS = 'syncwise/key_twitter_bookmarks' 7 | export const KEY_SYNCED_TWITTER_BOOKMARKS_ID_LIST = 'syncwise/key_synced_twitter_bookmarks_id_list' 8 | 9 | export const MESSAGE_PAUSE_TWITTER_BOOKMARKS_COLLECTION = 'message/pause_twitter_bookmarks_collection' 10 | 11 | export const MESSAGE_COLLECT_TWEETS_BOOKMARKS = 'message/collect_tweets_bookmarks' 12 | export const MESSAGE_SYNC_TO_LOGSEQ = 'message/sync_to_logseq' 13 | export const MESSAGE_GET_PHASE_SPECIFIC_RAW_DATA = 'message/get_phase_specific_raw_data' 14 | 15 | export const MESSAGE_SAVE_DATA_TO_DB = 'message/message_save_data_to_db' 16 | 17 | /** 18 | * Obsidian 19 | */ 20 | export const MESSAGE_SYNC_TO_OBSIDIAN = 'message/sync_to_obsidian' 21 | 22 | export const MESSAGE_ORIGIN_POPUP = 'MESSAGE_ORIGIN_POPUP' 23 | export const MESSAGE_ORIGIN_CONTENT = 'MESSAGE_ORIGIN_CONTENT' 24 | export const MESSAGE_ORIGIN_BACKGROUND = 'MESSAGE_ORIGIN_BACKGROUND' 25 | -------------------------------------------------------------------------------- /src/types/logseq/block.ts: -------------------------------------------------------------------------------- 1 | export type LogseqPageIdentity = { 2 | name: string 3 | id: number 4 | uuid: string 5 | } 6 | 7 | export type LogseqBlockType = { 8 | uuid: string 9 | html: string 10 | page: LogseqPageIdentity 11 | } 12 | 13 | export type LogseqPageContentType = { 14 | uuid: string 15 | content: string 16 | page: LogseqPageIdentity 17 | } 18 | 19 | export type LogseqSearchResult = { 20 | blocks: LogseqBlockType[] 21 | pages: LogseqPageIdentity[] 22 | // pageContents: LogseqPageContentType[]; 23 | graph: string 24 | } 25 | 26 | interface Macro { 27 | ident: string 28 | type: string 29 | properties: { 30 | logseq: { 31 | macroName: string 32 | macroArguments: string[] 33 | } 34 | } 35 | } 36 | 37 | export interface DataBlock { 38 | properties: Record 39 | tags: string[] 40 | pathRefs: string[] 41 | propertiesTextValues: Record 42 | uuid: string 43 | content: string 44 | macros: Macro[] 45 | page: number 46 | collapsed?: boolean 47 | propertiesOrder: string[] 48 | format: string 49 | refs: string[] 50 | } 51 | -------------------------------------------------------------------------------- /src/handler/output/logseq/index.ts: -------------------------------------------------------------------------------- 1 | import { beautifyLogseqText } from '@/parser/twitter/bookmark' 2 | import { blockRending } from '@/utils' 3 | import { DataBlock } from '@/types/logseq/block' 4 | import { getUnSyncedTwitterBookmarks } from '../background' 5 | import logseqClient from '@/pkms/logseq/client' 6 | import { getLogseqSyncConfig } from '@/config/logseq' 7 | 8 | export const saveToLogseq = async () => { 9 | const list = await getUnSyncedTwitterBookmarks() 10 | if (!Array.isArray(list) || list.length === 0) { 11 | console.log('all bookmarks are synced') 12 | return 13 | } 14 | const now = new Date() 15 | const resp = await logseqClient.getUserConfig() 16 | const formattedList: any = list.map((item) => { 17 | item.full_text = beautifyLogseqText(item.full_text as string, item.urls) 18 | return { 19 | ...item, 20 | preferredDateFormat: resp['preferredDateFormat'], 21 | time: now, 22 | } 23 | }) 24 | 25 | const blocks = formattedList.map((item: any) => blockRending(item)) 26 | const { pageName } = await getLogseqSyncConfig() 27 | // 如果 batch block 困难,可以 loop await。先暂时这样 28 | for (let i = 0; i < blocks.length; i++) { 29 | const resp1: DataBlock = await logseqClient.appendBlock(pageName, blocks[i][0]) 30 | await logseqClient.appendBlock(resp1.uuid, blocks[i][1]) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/constants/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18next from 'i18next' 2 | import { TranslateType } from '../i18n' 3 | import enUS from '../i18n/en-US.json' 4 | import zhCN from '../i18n/zh-CN.json' 5 | import zhTW from '../i18n/zh-TW.json' 6 | import jaJP from '../i18n/ja-JP.json' 7 | import { Lang, langList } from './langs' 8 | import Browser from 'webextension-polyfill' 9 | 10 | export function setLanguage(lang: Lang) { 11 | localStorage.setItem('language', lang) 12 | location.reload() 13 | } 14 | 15 | export const langs = langList.filter((it) => (['en-US', 'zh-CN', 'zh-TW', 'ja-JP'] as Lang[]).includes(it.value)) 16 | 17 | export const initI18n = async () => { 18 | await i18next.init({ 19 | lng: (await Browser.storage.sync.get('language')).language, 20 | fallbackLng: 'en-US', 21 | debug: true, 22 | resources: { 23 | 'en-US': { translation: enUS }, 24 | 'zh-CN': { translation: zhCN }, 25 | 'zh-TW': { translation: zhTW }, 26 | 'ja-JP': { translation: jaJP }, 27 | } as Record, 28 | keySeparator: false, 29 | }) 30 | } 31 | 32 | type T = TranslateType 33 | 34 | /** 35 | * Get the translated text according to the key 36 | * @param args 37 | */ 38 | export function t(...args: T[K]['params']): T[K]['value'] { 39 | // @ts-ignore 40 | return i18next.t(args[0], args[1]) 41 | } 42 | -------------------------------------------------------------------------------- /src/handler/output/obsidian/index.ts: -------------------------------------------------------------------------------- 1 | import { beautifyObsidianText } from '@/parser/twitter/bookmark' 2 | import obsidianClient from '@/pkms/obsidian/client' 3 | import { blockObsidianRending } from '@/utils' 4 | import { getUnSyncedTwitterBookmarks } from '../background' 5 | 6 | const saveToObsidianSharding = async (list: TweetBookmarkParsedItem[]) => { 7 | const formattedList: any = list.map((item) => { 8 | item.full_text = beautifyObsidianText(item.full_text as string, item.urls) 9 | return { 10 | ...item, 11 | } 12 | }) 13 | 14 | const mdContent = formattedList.reduce((accr: string, item: any) => { 15 | return accr + blockObsidianRending(item) 16 | }, '') 17 | 18 | console.log('mdContent', mdContent) 19 | // 切换到阅读模式 20 | const { pageName } = await obsidianClient.getObsidianSyncConfig() 21 | const resp = await obsidianClient.request(`/vault/${pageName}.md`, { 22 | method: 'POST', 23 | headers: { 24 | 'Content-Type': 'text/markdown', 25 | }, 26 | body: mdContent, 27 | }) 28 | console.log(resp) 29 | } 30 | 31 | export const saveToObsidian = async () => { 32 | const list = await getUnSyncedTwitterBookmarks() 33 | console.log('getUnSyncedTwitterBookmarks list', list) 34 | if (!Array.isArray(list) || list.length === 0) { 35 | console.log('all bookmarks are synced') 36 | return 37 | } 38 | 39 | // 将 list 切片上传 40 | while (list.length > 0) { 41 | const chunk = list.splice(0, 100) 42 | await saveToObsidianSharding(chunk) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Syncwise 2 | 3 | ## 下载插件 4 | 5 | - ~~[下载syncwise Chrome 插件](https://chromewebstore.google.com/detail/syncwise/cdndomegjfiajnafdkieddoaanfckfel)~~ 6 | 7 | - 下载 `release/chrome.zip` 8 | 9 | ## 同步指南 10 | 11 | ### 1. 启用笔记本同步 12 | 13 | #### Obsidian 同步设置 14 | 15 | - 请在 Obsidian 的插件市场下载并安装 **Local REST API** 插件。 16 | 17 | ![Obsidian 插件安装](./docs/obsidian-plugin.jpg) 18 | 19 | - 配置 **Local REST API** 插件。 20 | ![配置 Obsidian 插件](./docs/obsidian-plugin-config.jpg) 21 | - 如果开启了 Https 配置,需要按照**Local REST API** 插件指引配置证书 22 | - 或者不开启“enable encrypted(https) server”选项 23 | 24 | - 在 Syncwise 的配置页面确保 Obsidian 可以通过浏览器插件进行连接。 25 | ![检查 Obsidian 连接](./docs/obsidian-config.jpg) 26 | - 确保 http or https 配置和 Obsidian 的**Local REST API** 插件保持一致 27 | 28 | #### Logseq 同步设置 29 | 30 | - 在 Logseq 中启用 **Http API server** 功能。 31 | ![Logseq 设置](./docs/logseq-setting.jpg) 32 | - 开启 **Http API server** 并设置访问令牌(token)。 33 | ![启动 Logseq 服务器](./docs/logseq-server-start.jpg) 34 | ![设置 Logseq 访问令牌](./docs/logseq-token-setting.jpg) 35 | - 在 Syncwise 的配置页面检查是否可以通过浏览器插件连接到 Logseq。 36 | ![检查 Logseq 连接](./docs/check-logseq-connection.jpg) 37 | 38 | ### 2. 开启 Twitter 笔记同步 39 | 40 | - 在 Twitter 页面上,点击 Syncwise 面板的【开始收集】按钮,并等待页面自动滚动至底部。 41 | ![开始收集笔记](./docs/syncwise-collect-start.jpg) 42 | - 最后点击同步到笔记按钮,即可成功同步 43 | 44 | ## 常见问题解答 45 | 46 | ### Q: 使用此功能会导致账号被封锁吗? 47 | **A:** 不会。 48 | 49 | ### Q: 数据的隐私性如何? 50 | **A:** 我**高度**重视你的隐私保护,纯本地代码。 51 | 52 | 53 | ## 致谢 54 | 55 | - [rxliuli/clean-twitter](https://github.com/rxliuli/clean-twitter) 56 | 57 | - [hkgnp/chrome-extension-logseq-quickcapture](https://github.com/hkgnp/chrome-extension-logseq-quickcapture) 58 | - Others 59 | 60 | ## License 61 | 62 | MIT 63 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { 3 | MESSAGE_COLLECT_TWEETS_BOOKMARKS, 4 | MESSAGE_ORIGIN_BACKGROUND, 5 | MESSAGE_SYNC_TO_OBSIDIAN, 6 | MESSAGE_SYNC_TO_LOGSEQ, 7 | MESSAGE_PAUSE_TWITTER_BOOKMARKS_COLLECTION, 8 | } from '@/constants/twitter' 9 | import { saveToLogseq } from './handler/output/logseq' 10 | import { saveToObsidian } from './handler/output/obsidian' 11 | 12 | // 监听消息 13 | Browser.runtime.onMessage.addListener(async function (message, sender, sendResponse) { 14 | console.log('background JS chrome.runtime.onMessage.addListener::', message, JSON.stringify(message)) 15 | if (message?.type === 'OPEN_OPTIONS_PAGE') { 16 | Browser.runtime.openOptionsPage() 17 | } else if (message.type === MESSAGE_SYNC_TO_LOGSEQ) { 18 | saveToLogseq() 19 | } else if (message.type === MESSAGE_SYNC_TO_OBSIDIAN) { 20 | saveToObsidian() 21 | } else if (message.type === MESSAGE_COLLECT_TWEETS_BOOKMARKS) { 22 | Browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { 23 | const tab: any = tabs[0] 24 | if (tab) { 25 | Browser.tabs.sendMessage(tab.id, { 26 | from: MESSAGE_ORIGIN_BACKGROUND, 27 | type: MESSAGE_COLLECT_TWEETS_BOOKMARKS, 28 | }) 29 | } 30 | }) 31 | } else if (message.type === MESSAGE_PAUSE_TWITTER_BOOKMARKS_COLLECTION) { 32 | Browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { 33 | const tab: any = tabs[0] 34 | if (tab) { 35 | Browser.tabs.sendMessage(tab.id, { 36 | from: MESSAGE_ORIGIN_BACKGROUND, 37 | type: MESSAGE_PAUSE_TWITTER_BOOKMARKS_COLLECTION, 38 | }) 39 | } 40 | }) 41 | } 42 | 43 | // forward to popup 44 | sendResponse() 45 | }) 46 | -------------------------------------------------------------------------------- /src/content-script/utils/store.ts: -------------------------------------------------------------------------------- 1 | import { KEY_SYNCED_TWITTER_BOOKMARKS_ID_LIST, KEY_TWITTER_BOOKMARKS } from '../../constants/twitter' 2 | 3 | type TCallback = (args: Record) => void 4 | 5 | class LocalStorageStore { 6 | private storeKey: string 7 | 8 | constructor(storeKey: string) { 9 | this.storeKey = storeKey 10 | } 11 | 12 | // 创建或更新记录 13 | upsert(data: T, fn?: TCallback): void { 14 | const currentData = this.load() 15 | if (currentData == null) { 16 | this.save(data, fn) 17 | } else { 18 | this.insert(data, fn) 19 | } 20 | } 21 | 22 | load(): T | null { 23 | const data = localStorage.getItem(this.storeKey) 24 | return data ? JSON.parse(data) : null 25 | } 26 | 27 | // 删除数据 28 | delete(): void { 29 | localStorage.removeItem(this.storeKey) 30 | } 31 | 32 | // 创建或更新数据 33 | private save(data: T, fn?: TCallback): void { 34 | localStorage.setItem(this.storeKey, JSON.stringify(data)) 35 | fn?.({ 36 | length: (data as any).length, 37 | }) 38 | } 39 | 40 | // 读取数据 41 | 42 | // 去重更新数据 43 | private insert(data: T, fn?: TCallback): void { 44 | const currentData = this.load() 45 | let list: any = [] 46 | 47 | if (Array.isArray(currentData)) { 48 | const savedList = currentData.map((item: any) => item.id) 49 | list = (data as any).filter((item: any) => item.id && !savedList.includes(item.id)) 50 | } 51 | const oldList: any = currentData ?? [] 52 | this.save([...oldList, ...list] as any, fn) 53 | } 54 | } 55 | 56 | // 或许加上这个人的 ID,以防一个浏览器里多个 X 账号 57 | const bookmarksStore = new LocalStorageStore(KEY_TWITTER_BOOKMARKS) 58 | const syncedBookmarksStore = new LocalStorageStore(KEY_SYNCED_TWITTER_BOOKMARKS_ID_LIST) 59 | 60 | export { bookmarksStore, syncedBookmarksStore } 61 | -------------------------------------------------------------------------------- /src/content-script/plugins/collect-twitter-bookmarks.ts: -------------------------------------------------------------------------------- 1 | import { TASK_TWITTER_BOOKMARKS_SCROLL_FOR_COLLECTION, TWITTER_BOOKMARKS_XHR_HIJACK } from '@/constants/twitter' 2 | import { isProduction } from '@/constants/env' 3 | 4 | let count = 0 5 | let tryTime = 0 6 | 7 | function scroll() { 8 | const value = localStorage.getItem(TASK_TWITTER_BOOKMARKS_SCROLL_FOR_COLLECTION) 9 | 10 | if (value !== 'init') { 11 | console.log('用户主动终止滚动') 12 | return false 13 | } 14 | 15 | console.log('scroll before', count) 16 | window.scrollBy(0, 2000) 17 | count++ 18 | 19 | if (!isProduction()) { 20 | // 自测 21 | if (count > 8) { 22 | console.log('测试环境已经滚动到页面底部了!') 23 | return false 24 | } 25 | } 26 | 27 | var scrollTop = window.scrollY || document.documentElement.scrollTop 28 | if (scrollTop + window.innerHeight >= document.body.scrollHeight) { 29 | // 到底了 30 | if (tryTime >= 3) { 31 | console.log('已经滚动到页面底部了!') 32 | return false 33 | } else { 34 | tryTime++ 35 | } 36 | } else { 37 | tryTime = 0 38 | } 39 | 40 | return true 41 | } 42 | 43 | function reset() { 44 | count = 0 45 | } 46 | 47 | export function scrollUntilLastBookmark() { 48 | const scrollInterval = setInterval(function () { 49 | const hasNext = scroll() 50 | if (!hasNext) { 51 | clearInterval(scrollInterval) 52 | localStorage.removeItem(TWITTER_BOOKMARKS_XHR_HIJACK) 53 | localStorage.removeItem(TASK_TWITTER_BOOKMARKS_SCROLL_FOR_COLLECTION) 54 | reset() 55 | } 56 | }, 1000) 57 | } 58 | 59 | export function collectTwitterBookmarks() { 60 | localStorage.setItem(TASK_TWITTER_BOOKMARKS_SCROLL_FOR_COLLECTION, 'init') 61 | if (window.location.pathname !== '/i/bookmarks') { 62 | location.href = '/i/bookmarks' 63 | } else { 64 | scrollUntilLastBookmark() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig, UserConfig } from 'vite' 3 | import react from '@vitejs/plugin-react' 4 | import { crx } from '@crxjs/vite-plugin' 5 | import { fileURLToPath, URL } from 'url' 6 | import manifest from './manifest.json' 7 | import { i18nextDtsGen } from '@liuli-util/rollup-plugin-i18next-dts-gen' 8 | import { firefox } from '@liuli-util/vite-plugin-firefox-dist' 9 | import { UserConfig as TestConfig } from 'vitest/config' 10 | import path from 'path' 11 | 12 | export default defineConfig(({ mode }) => { 13 | const plugins = [ 14 | react(), 15 | i18nextDtsGen({ 16 | dirs: ['src/i18n'], 17 | }) as any, 18 | ] 19 | if (mode !== 'web') { 20 | plugins.push( 21 | crx({ manifest }), 22 | firefox({ 23 | browser_specific_settings: { 24 | gecko: { 25 | // TODO: change 26 | id: '11', 27 | strict_min_version: '109.0', 28 | }, 29 | }, 30 | }) 31 | ) 32 | } 33 | return { 34 | plugins: plugins, 35 | base: './', 36 | build: { 37 | target: 'esnext', 38 | minify: false, 39 | cssMinify: false, 40 | rollupOptions: { 41 | input: { 42 | main: path.resolve(__dirname, './index.html'), 43 | }, 44 | }, 45 | }, 46 | server: { 47 | port: 5173, 48 | strictPort: true, 49 | hmr: { 50 | port: 5173, 51 | }, 52 | }, 53 | resolve: { 54 | alias: { 55 | '@': path.resolve(__dirname, './src'), 56 | }, 57 | }, 58 | test: { 59 | environment: 'happy-dom', 60 | setupFiles: ['./src/setupTests.ts'], 61 | } as TestConfig['test'], 62 | } as UserConfig 63 | }) 64 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { defaults, merge } from 'lodash-es' 3 | import { NoteSyncLocationType, NoteSyncTarget } from '../types/pkm.d' 4 | export type LogseqSyncConfig = { 5 | host: string 6 | port: string 7 | token: string | null 8 | pageType: NoteSyncLocationType 9 | pageName: string // 同步去哪里 10 | } 11 | 12 | export type ObsidianSyncConfig = { 13 | host: string 14 | port: string // http port 15 | token: string | null 16 | pageType: NoteSyncLocationType 17 | pageName: string // 同步去哪里 18 | insecureMode: boolean 19 | httpsPort: string 20 | } 21 | 22 | export type UserConfig = { 23 | target: NoteSyncTarget 24 | logseq: LogseqSyncConfig 25 | obsidian: ObsidianSyncConfig 26 | } 27 | 28 | const DEFAULT_PAGE_NAME = 'twitter bookmarks' 29 | 30 | const userConfigWithDefaultValue: UserConfig = { 31 | target: NoteSyncTarget.Obsidian, 32 | logseq: { 33 | host: '127.0.0.1', 34 | port: '12315', 35 | token: null, 36 | pageType: NoteSyncLocationType.CustomPage, 37 | pageName: DEFAULT_PAGE_NAME, // Default destination page for Logseq 38 | }, 39 | obsidian: { 40 | host: '127.0.0.1', 41 | port: '27123', 42 | httpsPort: '27124', 43 | token: null, 44 | pageType: NoteSyncLocationType.CustomPage, 45 | pageName: DEFAULT_PAGE_NAME, // Default destination page for Obsidian 46 | insecureMode: false, // 不安全模式 47 | }, 48 | } 49 | 50 | export async function getUserConfig(): Promise { 51 | const result = await Browser.storage.local.get(Object.keys(userConfigWithDefaultValue)) 52 | return defaults(result, userConfigWithDefaultValue) 53 | } 54 | 55 | export async function updateUserConfig(updates: Partial) { 56 | console.debug('update configs', updates) 57 | const currentConfig = await getUserConfig() // 获取当前配置 58 | const mergedConfig = merge({}, currentConfig, updates) // 合并配置 59 | return Browser.storage.local.set(mergedConfig) 60 | } 61 | -------------------------------------------------------------------------------- /src/scope/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { GearIcon, QuestionIcon } from '@primer/octicons-react' 2 | import { useCallback, useEffect, useState } from 'react' 3 | import Browser from 'webextension-polyfill' 4 | import logo from '@/assets/logo.png' 5 | import { getUserConfig } from '@/config/config' 6 | import { NoteSyncTarget } from '@/types/pkm.d' 7 | import Syncwise from './components/Syncwise' 8 | 9 | function PopupPage() { 10 | const [target, setTarget] = useState(null) 11 | const [count, setCount] = useState(0) 12 | 13 | useEffect(() => { 14 | getUserConfig().then((config) => { 15 | setTarget(config.target) 16 | }) 17 | }, []) 18 | 19 | useEffect(() => { 20 | ;(async () => { 21 | const obj = await Browser.storage.local.get('count') 22 | setCount(obj.count || 0) 23 | })() 24 | 25 | Browser.storage.local.onChanged.addListener((v) => { 26 | if (v.count.newValue !== v.count.oldValue) { 27 | setCount(v.count.newValue) 28 | } 29 | }) 30 | }, []) 31 | 32 | const openOptionsPage = useCallback(() => { 33 | Browser.runtime.sendMessage({ type: 'OPEN_OPTIONS_PAGE' }) 34 | }, []) 35 | 36 | if (!target) { 37 | } 38 | 39 | return ( 40 |
41 |
42 | 43 |

Syncwise

44 |
45 | 46 | 47 | 48 |
49 | 54 | {target ? :
你还未选择同步到哪个笔记
} 55 |
56 | ) 57 | } 58 | 59 | export default PopupPage 60 | -------------------------------------------------------------------------------- /src/content-script/utils/hijack-xhr.ts: -------------------------------------------------------------------------------- 1 | import { bookmarksStore } from './store' 2 | import { TWITTER_BOOKMARKS_XHR_HIJACK } from '../../constants/twitter' 3 | import { parseBookmarkResponse } from '../../parser/twitter' 4 | 5 | function hookXHR(options: { after(xhr: XMLHttpRequest): string | void }) { 6 | const send = XMLHttpRequest.prototype.send 7 | 8 | XMLHttpRequest.prototype.send = function () { 9 | this.addEventListener('readystatechange', () => { 10 | if (this.readyState === 4) { 11 | const r = options.after(this) 12 | if (!r) { 13 | return 14 | } 15 | Object.defineProperty(this, 'responseText', { 16 | value: r, 17 | writable: true, 18 | }) 19 | Object.defineProperty(this, 'response', { 20 | value: r, 21 | writable: true, 22 | }) 23 | } 24 | }) 25 | 26 | return send.apply(this, arguments as any) 27 | } 28 | } 29 | 30 | function filterEntries(list: any[]) { 31 | return list.filter((item) => !item.entryId.includes('cursor')) 32 | } 33 | 34 | export async function hijackXHR() { 35 | hookXHR({ 36 | after(xhr) { 37 | const isHijack = localStorage.getItem(TWITTER_BOOKMARKS_XHR_HIJACK) 38 | if (!isHijack) return 39 | 40 | if (/https:\/\/twitter.com\/i\/api\/graphql\/.*\/Bookmarks/.test(xhr.responseURL)) { 41 | if (xhr.responseType === '' || xhr.responseType === 'text') { 42 | const response = JSON.parse(xhr.responseText) 43 | // handleTweetDetail(response) 44 | const entries = filterEntries( 45 | response?.data?.bookmark_timeline_v2?.timeline?.instructions?.[0]?.entries ?? [] 46 | ) 47 | const parsedList = entries.map(parseBookmarkResponse) 48 | // 去重逻辑 49 | bookmarksStore.upsert(parsedList, (obj) => { 50 | console.log('upsert callback', obj.length) 51 | }) 52 | } 53 | } 54 | }, 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /src/content-script/main.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import injectScript from '@/content-script/inject-script?script&module' 3 | import { 4 | MESSAGE_COLLECT_TWEETS_BOOKMARKS, 5 | MESSAGE_GET_PHASE_SPECIFIC_RAW_DATA, 6 | MESSAGE_ORIGIN_BACKGROUND, 7 | MESSAGE_PAUSE_TWITTER_BOOKMARKS_COLLECTION, 8 | TASK_TWITTER_BOOKMARKS_SCROLL_FOR_COLLECTION, 9 | TWITTER_BOOKMARKS_XHR_HIJACK, 10 | } from '@/constants/twitter' 11 | import { collectTwitterBookmarks } from '@/content-script/plugins/collect-twitter-bookmarks' 12 | 13 | import { getUnsyncedTwitterBookmarks } from '@/content-script/handlers/twitter' 14 | import { bookmarksStore } from '@/content-script/utils/store' 15 | ;(function insertScript() { 16 | const script = document.createElement('script') 17 | script.src = Browser.runtime.getURL(injectScript) 18 | script.type = 'module' 19 | document.head.prepend(script) 20 | })() 21 | ;(function switchOnHijack() { 22 | localStorage.setItem(TWITTER_BOOKMARKS_XHR_HIJACK, '1') 23 | })() 24 | 25 | // 不能写成 async & 必须返回 true 26 | // 不然 background.js 不能拿到数据 27 | Browser.runtime.onMessage.addListener(function (message, sender, sendResponse: any) { 28 | if (message?.from === MESSAGE_ORIGIN_BACKGROUND) { 29 | if (message?.type === MESSAGE_COLLECT_TWEETS_BOOKMARKS) { 30 | collectTwitterBookmarks() 31 | } 32 | 33 | if (message?.type === MESSAGE_GET_PHASE_SPECIFIC_RAW_DATA) { 34 | getUnsyncedTwitterBookmarks(sendResponse) 35 | } 36 | 37 | if (message?.type === MESSAGE_PAUSE_TWITTER_BOOKMARKS_COLLECTION) { 38 | localStorage.setItem(TASK_TWITTER_BOOKMARKS_SCROLL_FOR_COLLECTION, 'pause') 39 | } 40 | } 41 | 42 | sendResponse() 43 | }) 44 | 45 | function delay(ms: number) { 46 | return new Promise((resolve) => setTimeout(resolve, ms)) 47 | } 48 | 49 | ;(async () => { 50 | let lastCount = 0 51 | while (true) { 52 | await delay(3000) 53 | const count = ((bookmarksStore.load() as any) ?? []).length 54 | try { 55 | if (lastCount !== count) { 56 | await Browser.storage.local.set({ count }) 57 | lastCount = count 58 | } 59 | } catch (e) { 60 | console.log('error in content script:', e) 61 | } 62 | } 63 | })() 64 | -------------------------------------------------------------------------------- /src/scope/popup/components/Syncwise.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@geist-ui/core' 2 | import React from 'react' 3 | import { NoteSyncTarget } from '@/types/pkm.d' 4 | import { 5 | MESSAGE_COLLECT_TWEETS_BOOKMARKS, 6 | MESSAGE_ORIGIN_POPUP, 7 | MESSAGE_PAUSE_TWITTER_BOOKMARKS_COLLECTION, 8 | MESSAGE_SYNC_TO_LOGSEQ, 9 | MESSAGE_SYNC_TO_OBSIDIAN, 10 | } from '@/constants/twitter' 11 | import Browser from 'webextension-polyfill' 12 | import { PlayIcon } from '@primer/octicons-react' 13 | import { Pause } from 'lucide-react' 14 | 15 | export default function Syncwise({ count, target }: any) { 16 | const handlePauseCollect = () => { 17 | Browser.runtime.sendMessage({ 18 | from: MESSAGE_ORIGIN_POPUP, 19 | type: MESSAGE_PAUSE_TWITTER_BOOKMARKS_COLLECTION, 20 | }) 21 | } 22 | 23 | const handleSync = () => { 24 | Browser.runtime.sendMessage({ 25 | from: MESSAGE_ORIGIN_POPUP, 26 | type: MESSAGE_SYNC_TO_LOGSEQ, 27 | }) 28 | } 29 | 30 | const handleCollect = () => { 31 | Browser.runtime.sendMessage({ 32 | from: MESSAGE_ORIGIN_POPUP, 33 | type: MESSAGE_COLLECT_TWEETS_BOOKMARKS, 34 | }) 35 | } 36 | 37 | const handleSyncToObsidian = () => { 38 | Browser.runtime.sendMessage({ 39 | from: MESSAGE_ORIGIN_POPUP, 40 | type: MESSAGE_SYNC_TO_OBSIDIAN, 41 | }) 42 | } 43 | 44 | return ( 45 | <> 46 |
已收集{count}条书签🔖
47 | 51 | 55 | 56 | {target === NoteSyncTarget.Logseq && ( 57 | 60 | )} 61 | {target === NoteSyncTarget.Obsidian && ( 62 | 65 | )} 66 | 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /src/pkms/obsidian/client.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { ObsidianSyncConfig } from '../../config/config' 3 | 4 | class ObsidianClient { 5 | private static instance: ObsidianClient 6 | 7 | private constructor() {} 8 | 9 | public static getInstance(): ObsidianClient { 10 | if (!ObsidianClient.instance) { 11 | ObsidianClient.instance = new ObsidianClient() 12 | } 13 | return ObsidianClient.instance 14 | } 15 | 16 | async getObsidianSyncConfig(): Promise { 17 | const data = await Browser.storage.local.get('obsidian') 18 | const { host, port, token, pageType, pageName, httpsPort, insecureMode } = data?.obsidian ?? {} 19 | return { 20 | host, 21 | port, 22 | token, 23 | pageType, 24 | pageName, 25 | httpsPort, 26 | insecureMode, 27 | } 28 | } 29 | 30 | public async checkConnectStatus(): Promise { 31 | const res = await this.request('/', { 32 | method: 'GET', 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | }, 36 | }) 37 | .then((res) => res.json()) 38 | .then((r) => { 39 | console.log('r', r) 40 | if (!r.authenticated) { 41 | return { msg: 'Failed to authenticate with Obsidian, please check if the token is correct.' } 42 | } 43 | return { msg: 'success' } 44 | }) 45 | .catch((e) => ({ 46 | status: 'error', 47 | msg: e?.message ?? 'unknown error, please check obsidian Local REST API plugin settings.', 48 | })) 49 | console.log('checkConnectStatus', res) 50 | return res 51 | } 52 | 53 | public async request(path: string, options: RequestInit): ReturnType { 54 | const { host, port, httpsPort, token, insecureMode } = await this.getObsidianSyncConfig() 55 | console.log('getObsidianSyncConfig', host, port, httpsPort, token, insecureMode) 56 | const requestOptions: RequestInit = { 57 | ...options, 58 | headers: { 59 | ...options.headers, 60 | Authorization: `Bearer ${token}`, 61 | }, 62 | method: options.method?.toUpperCase(), 63 | mode: 'cors', 64 | } 65 | console.log('obsidian requestOptions:', requestOptions) 66 | 67 | return fetch( 68 | `http${insecureMode ? '' : 's'}://${host}:${insecureMode ? port : httpsPort}${path}`, 69 | requestOptions 70 | ) 71 | } 72 | } 73 | 74 | const obsidianClient = ObsidianClient.getInstance() 75 | 76 | export default obsidianClient 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syncwise", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "pack-src": "rimraf src.zip && jszip-cli add src/ package.json .gitignore vite.config.ts tsconfig.json tsconfig.node.json -o ./release/src.zip", 11 | "pack-xpi": "web-ext build -s ./dist-firefox/ -o -a release/ -n firefox.zip", 12 | "pack-zip": "rimraf extension.zip && jszip-cli add dist/ -o ./release/chrome.zip", 13 | "pack-crx": "crx3 -z release/chrome.zip -p chrome.pem -o release/chrome.crx ./dist", 14 | "pack-all": "rimraf release && pnpm build && pnpm pack-xpi && pnpm pack-src && pnpm pack-zip", 15 | "start-firefox": "web-ext run -s ./dist", 16 | "start-chromium": "web-ext run -s ./dist --target=chromium", 17 | "deploy": "pnpm build --mode web && gh-pages -d dist/ --dotfiles", 18 | "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,md,json}\" --config ./.prettierrc --cache" 19 | }, 20 | "dependencies": { 21 | "@geist-ui/core": "^2.3.8", 22 | "@liuli-util/async": "^3.6.1", 23 | "@liuli-util/test": "^3.7.0", 24 | "@mswjs/interceptors": "^0.25.2", 25 | "@octokit/oauth-app": "^6.0.0", 26 | "@octokit/rest": "^20.0.1", 27 | "@primer/octicons-react": "^19.8.0", 28 | "copy-to-clipboard": "^3.3.3", 29 | "date-fns": "^3.0.6", 30 | "eventemitter3": "^5.0.1", 31 | "i18next": "^23.2.3", 32 | "idb": "^7.1.1", 33 | "jieba-wasm": "^0.0.2", 34 | "liquidjs": "^10.10.0", 35 | "lodash-es": "^4.17.21", 36 | "lucide-react": "^0.372.0", 37 | "marked": "^11.1.0", 38 | "natural": "^6.7.1", 39 | "octokit": "^3.1.0", 40 | "react": "^18.2.0", 41 | "react-dom": "^18.2.0", 42 | "react-use": "^17.4.0", 43 | "uuid": "^9.0.0", 44 | "webextension-polyfill": "^0.10.0" 45 | }, 46 | "devDependencies": { 47 | "@crxjs/vite-plugin": "2.0.0-beta.18", 48 | "@ffflorian/jszip-cli": "^3.4.1", 49 | "@liuli-util/rollup-plugin-i18next-dts-gen": "^0.4.3", 50 | "@liuli-util/vite-plugin-firefox-dist": "^0.2.1", 51 | "@types/express": "^4.17.18", 52 | "@types/fs-extra": "^11.0.1", 53 | "@types/lodash-es": "^4.17.7", 54 | "@types/react": "^18.0.37", 55 | "@types/react-dom": "^18.0.11", 56 | "@types/uuid": "^9.0.3", 57 | "@types/webextension-polyfill": "^0.10.0", 58 | "@vitejs/plugin-react": "^4.0.1", 59 | "autoprefixer": "^10.4.14", 60 | "crx3": "^1.1.3", 61 | "dotenv": "^16.3.1", 62 | "express": "^4.18.2", 63 | "fake-indexeddb": "^4.0.2", 64 | "fs-extra": "^11.1.1", 65 | "gh-pages": "^6.0.0", 66 | "happy-dom": "^10.11.2", 67 | "langchain": "^0.0.144", 68 | "open": "^9.1.0", 69 | "pathe": "^1.1.1", 70 | "postcss": "^8.4.24", 71 | "prettier": "^3.1.1", 72 | "rimraf": "^5.0.1", 73 | "tailwindcss": "^3.3.2", 74 | "typescript": "^5.0.2", 75 | "vite": "^4.4.9", 76 | "vite-plugin-top-level-await": "^1.3.1", 77 | "vitest": "^0.34.3", 78 | "web-ext": "^7.6.2" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import { Liquid } from 'liquidjs' 3 | 4 | const engine = new Liquid() 5 | 6 | export const removeUrlHash = (url: string) => { 7 | const hashIndex = url.indexOf('#') 8 | return hashIndex > 0 ? url.substring(0, hashIndex) : url 9 | } 10 | 11 | export const logseqTimeFormat = (date: Date): string => { 12 | return format(date, 'HH:mm') 13 | } 14 | 15 | const mappingVersionToNumbers = (version: string): Array => { 16 | return version 17 | .split('.') 18 | .slice(0, 3) 19 | .map((x) => { 20 | return parseInt(x.split('0')[0]) 21 | }) 22 | } 23 | 24 | export const versionCompare = (versionA: string, versionB: string) => { 25 | const [majorA, minorA, patchA] = mappingVersionToNumbers(versionA) 26 | const [majorB, minorB, patchB] = mappingVersionToNumbers(versionB) 27 | if (majorA < majorB) return -1 28 | if (majorA > majorB) return 1 29 | if (minorA < minorB) return -1 30 | if (minorA > minorB) return 1 31 | if (patchA < patchB) return -1 32 | if (patchA > patchB) return 1 33 | return 0 34 | } 35 | 36 | export function logseqEscape(str: string): string { 37 | // return str.replace(/([\[\{\(])/g, '\\$1'); 38 | return str 39 | } 40 | 41 | interface LogSeqRenderVariables { 42 | title: string 43 | url: string 44 | screen_name: string 45 | nickname: string 46 | rest_id: string 47 | full_text: string 48 | preferredDateFormat: any 49 | time: any 50 | } 51 | 52 | export function blockRending({ 53 | title, 54 | url, 55 | screen_name, 56 | nickname, 57 | rest_id, 58 | full_text, 59 | preferredDateFormat, 60 | time, 61 | }: LogSeqRenderVariables): [string, string] { 62 | console.log(preferredDateFormat) 63 | 64 | // collapsed:: true 65 | // {% raw %}{{twitter {% endraw %}{{url}}{% raw %}}}{% endraw %} 66 | 67 | // TODO: better 68 | const template1 = `[{{nickname}}@{{screen_name}}](https://twitter.com/{{screen_name}}):{{full_text}}` 69 | 70 | const render1 = engine 71 | .parseAndRenderSync(template1, { 72 | date: format(time, preferredDateFormat), 73 | 74 | title: title, 75 | url: url, 76 | full_text: logseqEscape(full_text), 77 | screen_name, 78 | rest_id, 79 | nickname, 80 | 81 | time: logseqTimeFormat(time), 82 | dt: time, 83 | }) 84 | .trim() 85 | 86 | const template2 = `{% raw %}{{twitter {% endraw %}{{url}}{% raw %}}}{% endraw %}` 87 | 88 | const render2 = engine 89 | .parseAndRenderSync(template2, { 90 | date: format(time, preferredDateFormat), 91 | url: url, 92 | time: logseqTimeFormat(time), 93 | dt: time, 94 | }) 95 | .trim() 96 | 97 | return [render1, render2] 98 | } 99 | 100 | // TODO: 也许可以选择多种模板 101 | export function blockObsidianRending({ 102 | title, 103 | url, 104 | screen_name, 105 | nickname, 106 | rest_id, 107 | full_text, 108 | }: LogSeqRenderVariables): string { 109 | const template1 = ` 110 | --- 111 | 112 | [{{nickname}}@{{screen_name}}](https://twitter.com/{{screen_name}}): 113 | 114 | {{full_text}} 115 | 116 | [帖子详情链接]({{url}}) 117 | 118 | --- 119 | ` 120 | 121 | const render1 = engine 122 | .parseAndRenderSync(template1, { 123 | title: title, 124 | url: url, 125 | full_text: full_text, 126 | screen_name, 127 | rest_id, 128 | nickname, 129 | }) 130 | .trim() 131 | 132 | return render1 133 | } 134 | -------------------------------------------------------------------------------- /src/parser/twitter/bookmark.ts: -------------------------------------------------------------------------------- 1 | export function parseBookmarkResponse(tweetData: TweetEntry): TweetBookmarkParsedItem { 2 | const { rest_id, core, legacy, note_tweet } = tweetData?.content?.itemContent?.tweet_results?.result ?? {} 3 | const images = (legacy?.entities?.media ?? []).map((x) => x?.media_url_https) 4 | const screen_name = core?.user_results?.result?.legacy?.screen_name 5 | const url = `https://twitter.com/${screen_name}/status/${rest_id}` 6 | const id = rest_id 7 | const nickname = core?.user_results?.result?.legacy?.name ?? 'no nickname' 8 | 9 | const uniqueUrls: any = {} 10 | const urls: any = [] 11 | 12 | ;(legacy?.entities?.urls ?? []).forEach((x: any) => { 13 | if (!uniqueUrls.hasOwnProperty(x.display_url)) { 14 | uniqueUrls[x.display_url as string] = true // 标记 label 为已处理 15 | urls.push({ label: x.display_url, value: x.url }) 16 | } 17 | }) 18 | 19 | return { 20 | id, 21 | url, 22 | rest_id, 23 | nickname, 24 | screen_name, 25 | full_text: note_tweet?.note_tweet_results?.result?.text ?? legacy?.full_text, 26 | images, 27 | urls, 28 | } 29 | } 30 | 31 | /** 32 | * twitter to logseq 33 | */ 34 | export function beautifyLogseqText(text: string, urls: UrlItem[]) { 35 | if (!text) { 36 | return '' 37 | } 38 | // \r\n 换行 39 | // # twitter hash 40 | let str1 = text.replace(/\n\n/g, '\r\n').replace(/#/g, '') 41 | 42 | if (urls.length > 0) { 43 | console.log('str1 before:', str1, urls) 44 | 45 | urls.forEach((item) => { 46 | const { label, value } = item 47 | const markdownUrl = `[${label}](${value})` 48 | const regex = new RegExp(value, 'g') 49 | str1 = str1.replace(regex, () => markdownUrl) 50 | }) 51 | console.log('str1 after:', str1, urls) 52 | } 53 | // 用户数 > 5000 54 | const entities: any = { 55 | '<': '<', 56 | '>': '>', 57 | '&': '&', 58 | '"': '"', 59 | ''': "'", 60 | '¢': '¢', 61 | '£': '£', 62 | '¥': '¥', 63 | '€': '€', 64 | '©': '©', 65 | '®': '®', 66 | '™': '™', 67 | ' ': ' ', 68 | '¡': '¡', 69 | '¤': '¤', 70 | '¦': '¦', 71 | '§': '§', 72 | '¨': '¨', 73 | 'ª': 'ª', 74 | '«': '«', 75 | '¬': '¬', 76 | '­': '­', 77 | '¯': '¯', 78 | '°': '°', 79 | '±': '±', 80 | '²': '²', 81 | '³': '³', 82 | '´': '´', 83 | 'µ': 'µ', 84 | '¶': '¶', 85 | '·': '·', 86 | '¸': '¸', 87 | '¹': '¹', 88 | 'º': 'º', 89 | '»': '»', 90 | '¼': '¼', 91 | '½': '½', 92 | '¾': '¾', 93 | '¿': '¿', 94 | '×': '×', 95 | '÷': '÷', 96 | // ...可以继续添加更多 97 | } 98 | 99 | str1 = str1.replace(/&[a-zA-Z]+;/g, (match) => entities[match] || match) 100 | return str1 101 | } 102 | 103 | /** 104 | * twitter to obsidian 105 | */ 106 | export function beautifyObsidianText(text: string, urls: UrlItem[]) { 107 | if (!text) { 108 | return '' 109 | } 110 | let str1 = text 111 | 112 | if (urls.length > 0) { 113 | urls.forEach((item) => { 114 | const { label, value } = item 115 | const markdownUrl = `[${label}](${value})` 116 | // 逐个替换文本中的每个 URL 实例 117 | const regex = new RegExp(value, 'g') 118 | str1 = str1.replace(regex, () => markdownUrl) 119 | }) 120 | console.log('str1 after:', str1, urls) 121 | } 122 | // 用户数 > 5000 123 | const entities: any = { 124 | '<': '<', 125 | '>': '>', 126 | '&': '&', 127 | '"': '"', 128 | ''': "'", 129 | '¢': '¢', 130 | '£': '£', 131 | '¥': '¥', 132 | '€': '€', 133 | '©': '©', 134 | '®': '®', 135 | '™': '™', 136 | ' ': ' ', 137 | '¡': '¡', 138 | '¤': '¤', 139 | '¦': '¦', 140 | '§': '§', 141 | '¨': '¨', 142 | 'ª': 'ª', 143 | '«': '«', 144 | '¬': '¬', 145 | '­': '­', 146 | '¯': '¯', 147 | '°': '°', 148 | '±': '±', 149 | '²': '²', 150 | '³': '³', 151 | '´': '´', 152 | 'µ': 'µ', 153 | '¶': '¶', 154 | '·': '·', 155 | '¸': '¸', 156 | '¹': '¹', 157 | 'º': 'º', 158 | '»': '»', 159 | '¼': '¼', 160 | '½': '½', 161 | '¾': '¾', 162 | '¿': '¿', 163 | '×': '×', 164 | '÷': '÷', 165 | // ...可以继续添加更多 166 | } 167 | 168 | str1 = str1.replace(/&[a-zA-Z]+;/g, (match) => entities[match] || match) 169 | return str1 170 | } 171 | -------------------------------------------------------------------------------- /src/constants/langs.ts: -------------------------------------------------------------------------------- 1 | // ref: https://www.techonthenet.com/js/language_tags.php 2 | export type Lang = 3 | | 'ar-SA' 4 | | 'bn-BD' 5 | | 'bn-IN' 6 | | 'cs-CZ' 7 | | 'da-DK' 8 | | 'de-AT' 9 | | 'de-CH' 10 | | 'de-DE' 11 | | 'el-GR' 12 | | 'en-AU' 13 | | 'en-CA' 14 | | 'en-GB' 15 | | 'en-IE' 16 | | 'en-IN' 17 | | 'en-NZ' 18 | | 'en-US' 19 | | 'en-ZA' 20 | | 'es-AR' 21 | | 'es-CL' 22 | | 'es-CO' 23 | | 'es-ES' 24 | | 'es-MX' 25 | | 'es-US' 26 | | 'fi-FI' 27 | | 'fr-BE' 28 | | 'fr-CA' 29 | | 'fr-CH' 30 | | 'fr-FR' 31 | | 'he-IL' 32 | | 'hi-IN' 33 | | 'hu-HU' 34 | | 'id-ID' 35 | | 'it-CH' 36 | | 'it-IT' 37 | | 'ja-JP' 38 | | 'ko-KR' 39 | | 'nl-BE' 40 | | 'nl-NL' 41 | | 'no-NO' 42 | | 'pl-PL' 43 | | 'pt-BR' 44 | | 'pt-PT' 45 | | 'ro-RO' 46 | | 'ru-RU' 47 | | 'sk-SK' 48 | | 'sv-SE' 49 | | 'ta-IN' 50 | | 'ta-LK' 51 | | 'th-TH' 52 | | 'tr-TR' 53 | | 'zh-CN' 54 | | 'zh-HK' 55 | | 'zh-TW' 56 | 57 | export const langList: { value: Lang; label: string }[] = [ 58 | { 59 | value: 'ar-SA', 60 | label: 'العربية (المملكة العربية السعودية)', 61 | }, 62 | { 63 | value: 'bn-BD', 64 | label: 'বাংলা (বাংলাদেশ)', 65 | }, 66 | { 67 | value: 'bn-IN', 68 | label: 'বাংলা (ভারত)', 69 | }, 70 | { 71 | value: 'cs-CZ', 72 | label: 'čeština (Česká republika)', 73 | }, 74 | { 75 | value: 'da-DK', 76 | label: 'dansk (Danmark)', 77 | }, 78 | { 79 | value: 'de-AT', 80 | label: 'Österreichisches Deutsch', 81 | }, 82 | { 83 | value: 'de-CH', 84 | label: 'Schweizer Hochdeutsch', 85 | }, 86 | { 87 | value: 'de-DE', 88 | label: 'Standarddeutsch (wie es in Deutschland gesprochen wird)', 89 | }, 90 | { 91 | value: 'el-GR', 92 | label: 'Νέα Ελληνικά (Ελλάδα)', 93 | }, 94 | { 95 | value: 'en-AU', 96 | label: 'Australian English', 97 | }, 98 | { 99 | value: 'en-CA', 100 | label: 'Canadian English', 101 | }, 102 | { 103 | value: 'en-GB', 104 | label: 'British English', 105 | }, 106 | { 107 | value: 'en-IE', 108 | label: 'Irish English', 109 | }, 110 | { 111 | value: 'en-IN', 112 | label: 'Indian English', 113 | }, 114 | { 115 | value: 'en-NZ', 116 | label: 'New Zealand English', 117 | }, 118 | { 119 | value: 'en-US', 120 | label: 'English', 121 | }, 122 | { 123 | value: 'en-ZA', 124 | label: 'English (South Africa)', 125 | }, 126 | { 127 | value: 'es-AR', 128 | label: 'Español de Argentina', 129 | }, 130 | { 131 | value: 'es-CL', 132 | label: 'Español de Chile', 133 | }, 134 | { 135 | value: 'es-CO', 136 | label: 'Español de Colombia', 137 | }, 138 | { 139 | value: 'es-ES', 140 | label: 'Español de España', 141 | }, 142 | { 143 | value: 'es-MX', 144 | label: 'Español de México', 145 | }, 146 | { 147 | value: 'es-US', 148 | label: 'Español de Estados Unidos', 149 | }, 150 | { 151 | value: 'fi-FI', 152 | label: 'Suomi (Suomi)', 153 | }, 154 | { 155 | value: 'fr-BE', 156 | label: 'français de Belgique', 157 | }, 158 | { 159 | value: 'fr-CA', 160 | label: 'français canadien', 161 | }, 162 | { 163 | value: 'fr-CH', 164 | label: 'français suisse', 165 | }, 166 | { 167 | value: 'fr-FR', 168 | label: 'français standard (surtout en France)', 169 | }, 170 | { 171 | value: 'he-IL', 172 | label: 'עברית (ישראל)', 173 | }, 174 | { 175 | value: 'hi-IN', 176 | label: 'हिन्दी (भारत)', 177 | }, 178 | { 179 | value: 'hu-HU', 180 | label: 'Magyar (Magyarország)', 181 | }, 182 | { 183 | value: 'id-ID', 184 | label: 'Bahasa Indonesia (Indonesia)', 185 | }, 186 | { 187 | value: 'it-CH', 188 | label: 'Italiano svizzero', 189 | }, 190 | { 191 | value: 'it-IT', 192 | label: 'Italiano standard (come si parla in Italia)', 193 | }, 194 | { 195 | value: 'ja-JP', 196 | label: '日本語 (日本)', 197 | }, 198 | { 199 | value: 'ko-KR', 200 | label: '한국어 (대한민국)', 201 | }, 202 | { 203 | value: 'nl-BE', 204 | label: 'Nederlands van België', 205 | }, 206 | { 207 | value: 'nl-NL', 208 | label: 'Standaard Nederlands (zoals gesproken in Nederland)', 209 | }, 210 | { 211 | value: 'no-NO', 212 | label: 'Norsk (Norge)', 213 | }, 214 | { 215 | value: 'pl-PL', 216 | label: 'Polski (Polska)', 217 | }, 218 | { 219 | value: 'pt-BR', 220 | label: 'Português do Brasil', 221 | }, 222 | { 223 | value: 'pt-PT', 224 | label: 'Português europeu (como escrito e falado em Portugal)', 225 | }, 226 | { 227 | value: 'ro-RO', 228 | label: 'Română (România)', 229 | }, 230 | { 231 | value: 'ru-RU', 232 | label: 'Русский (Россия)', 233 | }, 234 | { 235 | value: 'sk-SK', 236 | label: 'Slovenčina (Slovenská republika)', 237 | }, 238 | { 239 | value: 'sv-SE', 240 | label: 'Svenska (Sverige)', 241 | }, 242 | { 243 | value: 'ta-IN', 244 | label: 'தமிழ் (இந்தியா)', 245 | }, 246 | { 247 | value: 'ta-LK', 248 | label: 'தமிழ் (இலங்கை)', 249 | }, 250 | { 251 | value: 'th-TH', 252 | label: 'ไทย (ประเทศไทย)', 253 | }, 254 | { 255 | value: 'tr-TR', 256 | label: 'Türkçe (Türkiye)', 257 | }, 258 | { 259 | value: 'zh-CN', 260 | label: '简体中文(中国大陆)', 261 | }, 262 | { 263 | value: 'zh-HK', 264 | label: '繁體中文(香港特別行政區)', 265 | }, 266 | { 267 | value: 'zh-TW', 268 | label: '繁體中文(台灣)', 269 | }, 270 | ] 271 | -------------------------------------------------------------------------------- /src/types/twitter/bookmark.d.ts: -------------------------------------------------------------------------------- 1 | interface TweetBookmarkResponse { 2 | data: { 3 | bookmark_timeline_v2: { 4 | timeline: { 5 | instructions: [ 6 | { 7 | entries: TweetEntry[] 8 | }, 9 | ] 10 | } 11 | } 12 | } 13 | } 14 | 15 | interface TweetEntry { 16 | entryId: string 17 | sortIndex: string 18 | content: TimelineTimelineItem 19 | } 20 | 21 | interface TimelineTimelineItem { 22 | entryType: string 23 | __typename: string 24 | itemContent: TimelineTweet 25 | } 26 | 27 | interface TimelineTweet { 28 | itemType: string 29 | __typename: string 30 | tweet_results: TweetResults 31 | } 32 | 33 | interface TweetResults { 34 | result: TweetResult 35 | } 36 | 37 | interface TweetResult { 38 | __typename: string 39 | rest_id: string 40 | core: Core 41 | unmention_data: any 42 | note_tweet?: NoteTweet // 只有在「展开的case」下出现 43 | edit_control: EditControl 44 | is_translatable: boolean 45 | views: Views 46 | source: string 47 | quoted_status_result: QuotedStatusResult 48 | legacy: TweetLegacy 49 | } 50 | 51 | interface Core { 52 | user_results: UserResults 53 | } 54 | 55 | interface UserResults { 56 | result: User 57 | } 58 | 59 | interface User { 60 | __typename: string 61 | id: string 62 | rest_id: string 63 | affiliates_highlighted_label: any 64 | has_graduated_access: boolean 65 | is_blue_verified: boolean 66 | profile_image_shape: string 67 | legacy: UserLegacy 68 | professional: Professional 69 | } 70 | 71 | interface UserLegacy { 72 | can_dm: boolean 73 | can_media_tag: boolean 74 | created_at: string 75 | default_profile: boolean 76 | default_profile_image: boolean 77 | description: string 78 | entities: UserEntities 79 | fast_followers_count: number 80 | favourites_count: number 81 | followers_count: number 82 | friends_count: number 83 | has_custom_timelines: boolean 84 | is_translator: boolean 85 | listed_count: number 86 | location: string 87 | media_count: number 88 | name: string 89 | normal_followers_count: number 90 | pinned_tweet_ids_str: string[] 91 | possibly_sensitive: boolean 92 | profile_banner_url: string 93 | profile_image_url_https: string 94 | profile_interstitial_type: string 95 | screen_name: string 96 | statuses_count: number 97 | translator_type: string 98 | verified: boolean 99 | want_retweets: boolean 100 | withheld_in_countries: any[] 101 | } 102 | 103 | interface UserEntities { 104 | description: Description 105 | } 106 | 107 | interface Description { 108 | urls: Url[] 109 | } 110 | 111 | interface Url { 112 | display_url: string 113 | expanded_url: string 114 | url: string 115 | indices: number[] 116 | } 117 | 118 | interface Professional { 119 | rest_id: string 120 | professional_type: string 121 | category: Category[] 122 | } 123 | 124 | interface Category { 125 | id: number 126 | name: string 127 | icon_name: string 128 | } 129 | 130 | interface EditControl { 131 | edit_tweet_ids: string[] 132 | editable_until_msecs: string 133 | is_edit_eligible: boolean 134 | edits_remaining: string 135 | } 136 | 137 | interface Views { 138 | count: string 139 | state: string 140 | } 141 | 142 | interface QuotedStatusResult { 143 | result: QuotedTweetResult 144 | } 145 | 146 | interface QuotedTweetResult { 147 | __typename: string 148 | rest_id: string 149 | core: QuotedCore 150 | unmention_data: any 151 | unified_card: UnifiedCard 152 | edit_control: EditControl 153 | is_translatable: boolean 154 | views: Views 155 | source: string 156 | legacy: QuotedTweetLegacy 157 | } 158 | 159 | interface QuotedCore { 160 | user_results: UserResults 161 | } 162 | 163 | interface UnifiedCard { 164 | card_fetch_state: string 165 | } 166 | 167 | interface QuotedTweetLegacy { 168 | bookmark_count: number 169 | bookmarked: boolean 170 | created_at: string 171 | conversation_id_str: string 172 | display_text_range: number[] 173 | entities: TweetEntities 174 | extended_entities: ExtendedEntities 175 | favorite_count: number 176 | favorited: boolean 177 | full_text: string 178 | is_quote_status: boolean 179 | lang: string 180 | possibly_sensitive: boolean 181 | possibly_sensitive_editable: boolean 182 | quote_count: number 183 | reply_count: number 184 | retweet_count: number 185 | retweeted: boolean 186 | user_id_str: string 187 | id_str: string 188 | } 189 | 190 | interface TweetEntities { 191 | hashtags: Hashtag[] 192 | media: Media[] 193 | symbols: any[] 194 | timestamps: any[] 195 | urls: Url[] 196 | user_mentions: UserMention[] 197 | } 198 | 199 | interface Hashtag { 200 | indices: number[] 201 | text: string 202 | } 203 | 204 | interface Media { 205 | display_url: string 206 | expanded_url: string 207 | id_str: string 208 | indices: number[] 209 | media_key: string 210 | media_url_https: string 211 | type: string 212 | url: string 213 | additional_media_info: AdditionalMediaInfo 214 | ext_media_availability: ExtMediaAvailability 215 | sizes: Sizes 216 | original_info: OriginalInfo 217 | video_info: VideoInfo 218 | } 219 | 220 | interface AdditionalMediaInfo { 221 | monetizable: boolean 222 | } 223 | 224 | interface ExtMediaAvailability { 225 | status: string 226 | } 227 | 228 | interface Sizes { 229 | large: SizeDetail 230 | medium: SizeDetail 231 | small: SizeDetail 232 | thumb: SizeDetail 233 | } 234 | 235 | interface SizeDetail { 236 | h: number 237 | w: number 238 | resize: string 239 | } 240 | 241 | interface OriginalInfo { 242 | height: number 243 | width: number 244 | focus_rects: any[] 245 | } 246 | 247 | interface VideoInfo { 248 | aspect_ratio: number[] 249 | duration_millis: number 250 | variants: Variant[] 251 | } 252 | 253 | interface Variant { 254 | bitrate?: number 255 | content_type: string 256 | url: string 257 | } 258 | 259 | interface UserMention { 260 | id_str: string 261 | name: string 262 | screen_name: string 263 | indices: number[] 264 | } 265 | 266 | interface ExtendedEntities { 267 | media: Media[] 268 | } 269 | 270 | interface TweetLegacy { 271 | bookmark_count: number 272 | bookmarked: boolean 273 | created_at: string 274 | conversation_id_str: string 275 | display_text_range: number[] 276 | entities: TweetEntities 277 | favorite_count: number 278 | favorited: boolean 279 | full_text: string // TODO: 全文 280 | is_quote_status: boolean 281 | lang: string 282 | quote_count: number 283 | quoted_status_id_str: string 284 | quoted_status_permalink: QuotedStatusPermalink 285 | reply_count: number 286 | retweet_count: number 287 | retweeted: boolean 288 | user_id_str: string 289 | id_str: string 290 | } 291 | 292 | interface QuotedStatusPermalink { 293 | url: string 294 | expanded: string 295 | display: string 296 | } 297 | 298 | // 展开更多 299 | 300 | interface NoteTweet { 301 | is_expandable: boolean 302 | note_tweet_results: NoteTweetResults 303 | } 304 | 305 | interface NoteTweetResults { 306 | result: NoteTweetResult 307 | } 308 | 309 | interface NoteTweetResult { 310 | id: string 311 | text: string 312 | entity_set: EntitySet 313 | } 314 | 315 | interface EntitySet { 316 | hashtags: any[] 317 | symbols: any[] 318 | timestamps: any[] 319 | urls: any[] 320 | user_mentions: any[] 321 | } 322 | -------------------------------------------------------------------------------- /src/pkms/logseq/client.ts: -------------------------------------------------------------------------------- 1 | import { LogseqSearchResult, LogseqPageIdentity, LogseqBlockType } from '../../types/logseq/block' 2 | import { marked } from 'marked' 3 | import { getLogseqSyncConfig } from '../../config/logseq' 4 | 5 | import { 6 | CannotConnectWithLogseq, 7 | LogseqVersionIsLower, 8 | TokenNotCorrect, 9 | UnknownIssues, 10 | NoSearchingResult, 11 | } from './error' 12 | import { LogseqSyncConfig } from '../../config/config' 13 | 14 | const logseqLinkExt = (graph: string, query: string) => { 15 | return { 16 | name: 'logseqLink', 17 | level: 'inline', 18 | tokenizer: function (src: string) { 19 | const match = src.match(/^#?\[\[(.*?)\]\]/) 20 | if (match) { 21 | return { 22 | type: 'logseqLink', 23 | raw: match[0], 24 | text: match[1], 25 | href: match[1].trim(), 26 | tokens: [], 27 | } 28 | } 29 | return false 30 | }, 31 | renderer: function (token: any) { 32 | const { text, href } = token 33 | const fillText = query ? text.replaceAll(query, '' + query + '') : text 34 | return `${fillText}` 35 | }, 36 | } 37 | } 38 | 39 | const highlightTokens = (query: string) => { 40 | return (token: any) => { 41 | if (token.type !== 'code' && token.type !== 'codespan' && token.type !== 'logseqLink' && token.text) { 42 | token.text = query ? token.text.replaceAll(query, '' + query + '') : token.text 43 | } 44 | } 45 | } 46 | 47 | type Graph = { 48 | name: string 49 | path: string 50 | } 51 | 52 | type LogseqSearchResponse = { 53 | 'blocks': { 54 | 'block/uuid': string 55 | 'block/content': string 56 | 'block/page': number 57 | }[] 58 | 'pages-content': { 59 | 'block/uuid': string 60 | 'block/snippet': string 61 | }[] 62 | 'pages': string[] 63 | } 64 | 65 | export type LogseqPageResponse = { 66 | 'name': string 67 | 'uuid': string 68 | 'journal?': boolean 69 | } 70 | 71 | export type LogseqResponseType = { 72 | status: number 73 | msg: string 74 | response: T 75 | count?: number 76 | } 77 | 78 | class LogseqClient { 79 | private static instance: LogseqClient 80 | 81 | private constructor() {} 82 | 83 | public static getInstance(): LogseqClient { 84 | if (!LogseqClient.instance) { 85 | LogseqClient.instance = new LogseqClient() 86 | } 87 | return LogseqClient.instance 88 | } 89 | 90 | async getLogseqSyncConfig(): Promise { 91 | return await getLogseqSyncConfig() 92 | } 93 | 94 | private baseFetch = async (method: string, args: any[]) => { 95 | const config = await getLogseqSyncConfig() 96 | const endPoint = new URL(`http://${config.host}:${config.port}`) 97 | const apiUrl = new URL(`${endPoint.origin}/api`) 98 | const body = JSON.stringify({ 99 | method: method, 100 | args: args, 101 | }) 102 | console.log(`logseq method:${method} body: ${body}`) 103 | const resp = await fetch(apiUrl, { 104 | mode: 'cors', 105 | method: 'POST', 106 | headers: { 107 | 'Authorization': `Bearer ${config.token}`, 108 | 'Content-Type': 'application/json; charset=utf-8', 109 | }, 110 | body: body, 111 | }) 112 | 113 | if (resp.status !== 200) { 114 | throw resp 115 | } 116 | 117 | return resp 118 | } 119 | 120 | private baseJson = async (method: string, args: any[]) => { 121 | const resp = await this.baseFetch(method, args) 122 | const data = await resp.json() 123 | console.log('logseq response data:', data) 124 | return data 125 | } 126 | 127 | private trimContent = (content: string): string => { 128 | return content 129 | .replace(/!\[.*?\]\(\.\.\/assets.*?\)/gm, '') 130 | .replace(/^[\w-]+::.*?$/gm, '') // clean properties 131 | .replace(/{{renderer .*?}}/gm, '') // clean renderer 132 | .replace(/^deadline: <.*?>$/gm, '') // clean deadline 133 | .replace(/^scheduled: <.*?>$/gm, '') // clean schedule 134 | .replace(/^:logbook:[\S\s]*?:end:$/gm, '') // clean logbook 135 | .replace(/\$pfts_2lqh>\$(.*?)\$$1') // clean highlight 136 | .replace(/^\s*?-\s*?$/gm, '') 137 | .trim() 138 | } 139 | 140 | private format = async (content: string, graphName: string, query: string) => { 141 | marked.use({ 142 | gfm: true, 143 | tables: true, 144 | walkTokens: highlightTokens(query), 145 | extensions: [logseqLinkExt(graphName, query)], 146 | } as any) 147 | const html = await marked.parse(this.trimContent(content)) 148 | return html.trim() 149 | } 150 | 151 | // public appendBlock = async (page: string, content: string) => { 152 | // const resp = await this.baseJson('logseq.Editor.appendBlockInPage', [ 153 | // page, 154 | // content, 155 | // ]); 156 | // return resp; 157 | // }; 158 | 159 | private getCurrentGraph = async (): Promise<{ 160 | name: string 161 | path: string 162 | }> => { 163 | const resp: Graph = await this.baseJson('logseq.getCurrentGraph', []) 164 | return resp 165 | } 166 | 167 | public appendBlock = async (page: string, content: string) => { 168 | const resp = await this.baseJson('logseq.Editor.appendBlockInPage', [page, content]) 169 | return resp 170 | } 171 | 172 | public appendBatchBlock = async (page: string, content: string[]) => { 173 | const resp = await this.baseJson('logseq.Editor.insertBatchBlock', [ 174 | page, 175 | content, 176 | { 177 | sibling: false, 178 | }, 179 | ]) 180 | return resp 181 | } 182 | 183 | public getCurrentPage = async () => { 184 | return await this.catchIssues(async () => { 185 | return await this.baseJson('logseq.Editor.getCurrentPage', []) 186 | }) 187 | } 188 | 189 | private getAllPage = async () => { 190 | return await this.baseJson('logseq.Editor.getAllPages', []) 191 | } 192 | 193 | private getPage = async (pageIdentity: LogseqPageIdentity): Promise => { 194 | const resp: LogseqPageIdentity = await this.baseJson('logseq.Editor.getPage', [ 195 | pageIdentity.id || pageIdentity.uuid || pageIdentity.name, 196 | ]) 197 | return resp 198 | } 199 | 200 | private search = async (query: string): Promise => { 201 | const resp = await this.baseJson('logseq.App.search', [query]) 202 | if (resp.error) { 203 | throw LogseqVersionIsLower 204 | } 205 | return resp 206 | } 207 | 208 | private showMsgInternal = async (message: string): Promise> => { 209 | await this.baseFetch('logseq.showMsg', [message]) 210 | return { 211 | status: 200, 212 | msg: 'success', 213 | response: null, 214 | } 215 | } 216 | 217 | private async catchIssues(func: Function) { 218 | try { 219 | return await func() 220 | } catch (e: any) { 221 | console.info(e) 222 | if (e.status === 401) { 223 | return TokenNotCorrect 224 | } else if (e.toString() === 'TypeError: Failed to fetch' || e.toString().includes('Invalid URL')) { 225 | return CannotConnectWithLogseq 226 | } else if (e === LogseqVersionIsLower || e === NoSearchingResult) { 227 | return e 228 | } else { 229 | return UnknownIssues 230 | } 231 | } 232 | } 233 | 234 | private getVersionInternal = async (): Promise> => { 235 | const resp = await this.baseJson('logseq.App.getAppInfo', []) 236 | return { 237 | status: 200, 238 | msg: 'success', 239 | response: resp.version, 240 | } 241 | } 242 | 243 | private find = async (query: string) => { 244 | const escapedQuery = query.replace(/"/g, '\\"') 245 | const data = await this.baseJson('logseq.DB.q', [`"${escapedQuery}"`]) 246 | return data 247 | } 248 | 249 | private findLogseqInternal = async (query: string): Promise> => { 250 | const { name: graphName } = await this.getCurrentGraph() 251 | const res = await this.find(query) 252 | const blocks = ( 253 | await Promise.all( 254 | res.map(async (item: any) => { 255 | const content = this.format(item.content, graphName, query) 256 | if (!content) return null 257 | return { 258 | html: content, 259 | uuid: item.uuid, 260 | page: await this.getPage({ 261 | id: item.page.id, 262 | } as LogseqPageIdentity), 263 | } as unknown as LogseqBlockType 264 | }) 265 | ) 266 | ).filter((b) => b) 267 | return { 268 | status: 200, 269 | msg: 'success', 270 | response: { 271 | blocks: blocks, 272 | pages: [], 273 | graph: graphName, 274 | }, 275 | count: blocks.length, 276 | } 277 | } 278 | 279 | private searchLogseqInternal = async (query: string): Promise> => { 280 | const { name: graphName } = await this.getCurrentGraph() 281 | const { blocks, pages }: LogseqSearchResponse = await this.search(query) 282 | 283 | const result: LogseqSearchResult = { 284 | pages: await Promise.all( 285 | pages.map(async (page) => await this.getPage({ name: page } as LogseqPageIdentity)) 286 | ), 287 | blocks: (await Promise.all( 288 | blocks.map(async (block) => { 289 | return { 290 | html: this.format(block['block/content'], graphName, query), 291 | uuid: block['block/uuid'], 292 | page: await this.getPage({ 293 | id: block['block/page'], 294 | } as LogseqPageIdentity), 295 | } 296 | }) 297 | )) as any, 298 | graph: graphName, 299 | } 300 | 301 | console.debug(result) 302 | return { 303 | msg: 'success', 304 | status: 200, 305 | response: result, 306 | count: result.blocks.length + result.pages.length, 307 | } 308 | } 309 | 310 | public getUserConfig = async () => { 311 | return await this.catchIssues(async () => await this.baseJson('logseq.App.getUserConfigs', [])) 312 | } 313 | 314 | public showMsg = async (message: string): Promise> => { 315 | return await this.catchIssues(async () => await this.showMsgInternal(message)) 316 | } 317 | 318 | public getAllPages = async (): Promise => { 319 | return await this.catchIssues(async () => await this.getAllPage()) 320 | } 321 | 322 | public getGraph = async (): Promise => { 323 | return await this.catchIssues(async () => await this.getCurrentGraph()) 324 | } 325 | 326 | public getVersion = async (): Promise> => { 327 | return await this.catchIssues(async () => await this.getVersionInternal()) 328 | } 329 | 330 | public searchLogseq = async (query: string): Promise> => { 331 | return await this.catchIssues(async () => { 332 | return await this.searchLogseqInternal(query.trim()) 333 | }) 334 | } 335 | 336 | public blockSearch = async (query: string): Promise> => { 337 | return await this.catchIssues(async () => { 338 | return this.findLogseqInternal(query) 339 | }) 340 | } 341 | } 342 | 343 | const logseqClient = LogseqClient.getInstance() 344 | 345 | export default logseqClient 346 | -------------------------------------------------------------------------------- /src/scope/options/App.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from 'react' 2 | import { Button, Grid, Input, Page, Radio, Spacer, Text, Toggle, Tooltip, useToasts } from '@geist-ui/core' 3 | import { NoteSyncLocationType, NoteSyncTarget } from '@/types/pkm.d' 4 | 5 | import { NOTE_TEXT } from '@/scope/options/config/note' 6 | import obsidianClient from '@/pkms/obsidian/client' 7 | import { getUserConfig, updateUserConfig } from '../../config/config' 8 | import { capitalize } from 'lodash-es' 9 | import { QuestionIcon } from '@primer/octicons-react' 10 | import logseqClient from '@/pkms/logseq/client' 11 | 12 | export function App() { 13 | console.log('Options js init.') 14 | const [loading, setLoading] = useState(false) 15 | const [connected, setConnected] = useState(false) 16 | 17 | const [note, setNote] = useState(null) 18 | const [logseqHost, setLogseqHost] = useState('') 19 | const [logseqPort, setLogseqPort] = useState('') 20 | const [logseqToken, setLogseqToken] = useState('') 21 | const [logseqSyncLocationType, setLogseqSyncLocationType] = useState( 22 | NoteSyncLocationType.CustomPage 23 | ) 24 | const [logseqCustomPageName, setLogseqCustomPageName] = useState(null) 25 | 26 | const [obsidianHost, setObsidianHost] = useState('') 27 | const [obsidianHttpsPort, setObsidianHttpsPort] = useState('') 28 | const [isInsecureMode, setIsInsecureMode] = useState(false) 29 | const [obsidianPort, setObsidianPort] = useState('') 30 | const [obsidianToken, setObsidianToken] = useState('') 31 | const [obsidianSyncLocationType, setObsidianSyncLocationType] = useState( 32 | NoteSyncLocationType.CustomPage 33 | ) 34 | const [obsidianCustomPageName, setObsidianCustomPageName] = useState(null) 35 | 36 | const { setToast } = useToasts() 37 | 38 | useEffect(() => { 39 | getUserConfig().then((config) => { 40 | console.log('init config', config) 41 | setNote(config.target) 42 | // logseq 43 | setLogseqHost(config.logseq.host) 44 | setLogseqPort(config.logseq.port) 45 | setLogseqToken(config.logseq.token) 46 | setLogseqSyncLocationType(config.logseq.pageType) 47 | setLogseqCustomPageName(config.logseq.pageName) 48 | 49 | // obsidian 50 | setObsidianHost(config.obsidian.host) 51 | setObsidianPort(config.obsidian.port) 52 | setObsidianToken(config.obsidian.token) 53 | setObsidianHttpsPort(config.obsidian.httpsPort) 54 | setIsInsecureMode(config.obsidian.insecureMode) 55 | setObsidianSyncLocationType(config.obsidian.pageType) 56 | setObsidianCustomPageName(config.obsidian.pageName) 57 | }) 58 | // init save to local 59 | updateUserConfig({}) 60 | }, []) 61 | 62 | const onNoteChange = useCallback( 63 | (target: NoteSyncTarget) => { 64 | setNote(target) 65 | updateUserConfig({ target }) 66 | setToast({ text: 'Changes saved', type: 'success' }) 67 | }, 68 | [setToast] 69 | ) 70 | 71 | const host = useMemo( 72 | () => (note === NoteSyncTarget.Obsidian ? obsidianHost : logseqHost), 73 | [note, obsidianHost, logseqHost] 74 | ) 75 | const port = useMemo( 76 | () => (note === NoteSyncTarget.Logseq ? logseqPort : isInsecureMode ? obsidianPort : obsidianHttpsPort), 77 | [note, obsidianPort, logseqPort, isInsecureMode, obsidianHttpsPort] 78 | ) 79 | const token = useMemo( 80 | () => (note === NoteSyncTarget.Obsidian ? obsidianToken : logseqToken), 81 | [note, obsidianToken, logseqToken] 82 | ) 83 | 84 | const pageType = useMemo( 85 | () => (note === NoteSyncTarget.Obsidian ? obsidianSyncLocationType : logseqSyncLocationType), 86 | [note, obsidianSyncLocationType, logseqSyncLocationType] 87 | ) 88 | 89 | const pageName = useMemo( 90 | () => (note === NoteSyncTarget.Obsidian ? obsidianCustomPageName : logseqCustomPageName), 91 | [note, obsidianCustomPageName, logseqCustomPageName] 92 | ) 93 | 94 | const onHostChange = useCallback( 95 | (val: string) => { 96 | note === NoteSyncTarget.Logseq ? setLogseqHost(val) : setObsidianHost(val) 97 | updateUserConfig({ 98 | [note as string]: { 99 | host: val, 100 | }, 101 | }) 102 | }, 103 | [note] 104 | ) 105 | 106 | const onPortChange = useCallback( 107 | (val: string) => { 108 | note === NoteSyncTarget.Logseq 109 | ? setLogseqPort(val) 110 | : isInsecureMode 111 | ? setObsidianPort(val) 112 | : setObsidianHttpsPort(val) 113 | updateUserConfig({ 114 | [note as string]: { 115 | // obsidian 特有 116 | [isInsecureMode ? 'port' : 'httpsPort']: val, 117 | }, 118 | }) 119 | }, 120 | [note, isInsecureMode] 121 | ) 122 | 123 | const onTokenChange = useCallback( 124 | (val: string) => { 125 | note === NoteSyncTarget.Logseq ? setLogseqToken(val) : setObsidianToken(val) 126 | updateUserConfig({ 127 | [note as string]: { 128 | token: val, 129 | }, 130 | }) 131 | }, 132 | [note] 133 | ) 134 | 135 | const onSecureModeChange = (checked: boolean) => { 136 | setIsInsecureMode(!checked) 137 | updateUserConfig({ 138 | obsidian: { 139 | insecureMode: !checked, 140 | } as any, 141 | }) 142 | } 143 | 144 | const onPageTypeChange = useCallback( 145 | (val: NoteSyncLocationType) => { 146 | note === NoteSyncTarget.Logseq ? setLogseqSyncLocationType(val) : setObsidianSyncLocationType(val) 147 | updateUserConfig({ 148 | [note as string]: { 149 | pageType: val, 150 | }, 151 | }) 152 | }, 153 | [note] 154 | ) 155 | 156 | const onCustomPageChange = useCallback( 157 | (val: string) => { 158 | note === NoteSyncTarget.Logseq ? setLogseqCustomPageName(val) : setObsidianCustomPageName(val) 159 | updateUserConfig({ 160 | [note as string]: { 161 | pageName: val, 162 | }, 163 | }) 164 | }, 165 | [note] 166 | ) 167 | 168 | const checkConnection = useCallback(async () => { 169 | if (note === NoteSyncTarget.Logseq) { 170 | setLoading(true) 171 | const client = logseqClient 172 | const resp = await client.showMsg('Syncwise Connect!') 173 | const connectStatus = resp.msg === 'success' 174 | setConnected(connectStatus) 175 | if (connectStatus) { 176 | setToast({ text: `${capitalize(note as string)} Connect Succeed!`, type: 'success' }) 177 | } else { 178 | setConnected(false) 179 | setToast({ 180 | delay: 4000, 181 | text: `${capitalize(note as string)} Connect Failed! ${resp.msg}`, 182 | type: 'error', 183 | }) 184 | } 185 | setLoading(false) 186 | return connectStatus 187 | } else if (note === NoteSyncTarget.Obsidian) { 188 | setLoading(true) 189 | const client = obsidianClient 190 | const resp = await client.checkConnectStatus() 191 | const connectStatus = resp.msg === 'success' 192 | setConnected(connectStatus) 193 | if (connectStatus) { 194 | setToast({ text: `${capitalize(note as string)} Connect Succeed!`, type: 'success' }) 195 | } else { 196 | setConnected(false) 197 | setToast({ 198 | delay: 4000, 199 | text: `${capitalize(note as string)} Connect Failed! ${resp.msg}`, 200 | type: 'error', 201 | }) 202 | } 203 | setLoading(false) 204 | return connectStatus 205 | } 206 | }, [note]) 207 | 208 | console.log('pageType', pageType) 209 | 210 | return ( 211 | 212 |
213 | Options 214 | 215 | Note Sync Target 216 | 217 | onNoteChange(val as NoteSyncTarget)}> 218 | {Object.entries(NOTE_TEXT).map(([value, texts]) => { 219 | return ( 220 | 221 | {texts.title} 222 | {texts.desc} 223 | 224 | ) 225 | })} 226 | 227 | 228 | 229 | 230 | {note && ( 231 | <> 232 | 233 | {capitalize(note ?? '')} Connect 234 | 235 | {note === NoteSyncTarget.Obsidian && ( 236 |
237 | Enable Encrypted(HTTPS) Server 238 | 239 | onSecureModeChange(e?.target?.checked)} 244 | /> 245 |
246 | )} 247 | 248 | 249 | onHostChange(e?.target?.value ?? '')} 251 | crossOrigin={undefined} 252 | value={host} 253 | > 254 | Host 255 | 256 | 257 | 258 | onPortChange(e?.target?.value ?? '') as any} 260 | crossOrigin={undefined} 261 | value={port} 262 | > 263 | {note === NoteSyncTarget.Obsidian && !isInsecureMode ? 'Https' : 'Http'} Port 264 | 265 | 266 | 267 | {/* 优化 Bearer 的输入*/} 268 | 269 | onTokenChange(e?.target?.value ?? '')} 272 | placeholder='Http Authorization Token' 273 | crossOrigin={undefined} 274 | value={token ?? ''} 275 | > 276 | Authorization Token 277 | 278 | 279 | )} 280 | 281 | 282 | 283 | 286 | 287 | 288 | 289 | {/* 笔记的文件名设置 */} 290 | 291 | {capitalize(note ?? '')} Config 292 | 293 |
294 | Sync Location 295 |
296 | onPageTypeChange(val as any)} useRow> 297 | Journal 298 | Custom Page 299 | 300 |
301 |
302 | 303 | {pageType === NoteSyncLocationType.CustomPage && ( 304 |
305 |
306 | Custom Page 307 | 312 |
313 | 314 |
315 |
316 |
317 |
318 | onCustomPageChange(e?.target?.value ?? '')} 320 | crossOrigin={undefined} 321 | value={pageName ?? ''} 322 | > 323 |
324 |
325 | )} 326 |
327 |
328 | ) 329 | } 330 | --------------------------------------------------------------------------------