├── 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 | 
18 |
19 | - 配置 **Local REST API** 插件。
20 | 
21 | - 如果开启了 Https 配置,需要按照**Local REST API** 插件指引配置证书
22 | - 或者不开启“enable encrypted(https) server”选项
23 |
24 | - 在 Syncwise 的配置页面确保 Obsidian 可以通过浏览器插件进行连接。
25 | 
26 | - 确保 http or https 配置和 Obsidian 的**Local REST API** 插件保持一致
27 |
28 | #### Logseq 同步设置
29 |
30 | - 在 Logseq 中启用 **Http API server** 功能。
31 | 
32 | - 开启 **Http API server** 并设置访问令牌(token)。
33 | 
34 | 
35 | - 在 Syncwise 的配置页面检查是否可以通过浏览器插件连接到 Logseq。
36 | 
37 |
38 | ### 2. 开启 Twitter 笔记同步
39 |
40 | - 在 Twitter 页面上,点击 Syncwise 面板的【开始收集】按钮,并等待页面自动滚动至底部。
41 | 
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 |
--------------------------------------------------------------------------------